logo
×

Tim KerrOctober 1, 2024

Inversion of control and observability for React codebases

Along with posting blogs to announce updates to the Labelbox platform, we also love to capture learnings and best practices from our engineering teams. Today we are sharing important lessons learned from building complex frontends with Inversion of Control (IoC). 

Read on to learn about how IoC can help React developers build adaptable, testable, and scalable applications by decoupling components and streamlining dependencies.

Introduction to Inversion of Control (IoC)

Inversion of Control (IoC) is a software engineering principle where the control of a program’s flow is transferred from the traditional flow of execution to a framework or external source. Instead of the program making direct calls to components or services when needed, the control is inverted, allowing an external entity (like a framework or container) to manage the instantiation and lifecycle of those components. 

In React, you can use IoC effectively with libraries like InversifyJS to manage dependency injection, and MobX to create observable services and view models that automatically re-render React components when the data changes. These two libraries together form a synergy that allow you to implement powerful abstractions with practically no boilerplate.

In this post, we’ll walk through what qualities we like to see in a React codebase and how IoC can help us get there. We’ll also walk through setting up IoC in a React application using InversifyJS and MobX.

What do we want to achieve in our front end codebases?

  • Low coupling: Inversion of Control (IoC) leads to low coupling in code by decoupling the implementation of a class from its dependencies, allowing flexibility in how those dependencies are provided. Services and view models implement the dependency injection pattern that provides dependencies of the module in the constructor of the class. These dependencies are referenced by their abstraction (interface) as opposed to their concrete implementation. This unlocks the ability to easily swap out a concrete implementation of a dependency based on the needs of our current application.
  • Component portability: Traditionally, React components tightly manage their dependencies, often importing and instantiating services directly in the component body via hooks. This approach tightly couples components to specific implementations, reducing reusability. With IoC, components no longer manage their own dependencies. Instead, dependencies like services are injected at runtime through an IoC container. This decoupling allows the component to be used in different contexts by simply injecting different implementations of the services.
  • Testability: Given that React components typically manage their dependencies directly, this makes it difficult to swap these dependencies for mocks or stubs during testing, leading to complex testing setups. With IoC, all classes take in their dependencies through the constructor, which makes providing mock services and data very straightforward.

Using IoC in React

Setting up the IoC Container Provider

The IoC container is the mechanism that will direct the control flow of our application. We define a React provider that exposes the InversifyJS IoC container to the rest of our application like this:

export const IocContainerProvider: React.FC<
React.PropsWithChildren
> = ({ children }) => {
const [initialized, setInitialized] = useState(false);
const iocContainerRef = useRef(new Container());
const iocContextValue = useRef<IocContainerContext>();

if (!initialized) {
  const container = iocContainerRef.current;

  // Bind service interfaces to their implementations
  container
    .bind<IExampleService>(IOC_INTERFACE_TYPES.IExampleService)
    .to(ExampleService)
    .inSingletonScope();

  // Resolve the services from the ioc container and make them available to consumers.
  // Services should ONLY be resolved with container.get(...) in an IOC provider as close to
  // the application root as possible to avoid the service locator anti-pattern.
  // container.get(...) should never be invoked directly by services, hooks, or components.
  const exampleService = container.get<IExampleService>(
    IOC_INTERFACE_TYPES.IExampleService
  );
 
  // Expose the resolved service instances to the rest of the application via React context.
  iocContextValue.current = {
    container,
    services: {
      exampleService
    },
  };

  setInitialized(true);
}

return (
  <IocContianer.Provider value={iocContextValue.current}>
    {children}
  </IocContianer.Provider>
);
};

Creating a View Model that consumes a service managed by the IoC Container

In consumer classes, dependencies are taken in as parameters in the constructor and referenced by their abstraction (interface). This allows the underlying implementation of the dependency to be swapped out if need be in a different context.

export class ExampleViewModel {
constructor(private readonly exampleService: IExampleService) {
  makeAutoObservable(this); // Properties and getters are observable with MobX
}
// Observable property that will re-render a react component if it changes.
get someValue() {
  return this.exampleService.someValue;
}

doSomething() {
  this.exampleService.doSomething();
}
}

Observability with MobX in React

MobX automatically triggers React component re-renders by making properties on a referenced model observable. When a component references an observable property on a MobX model, MobX tracks this dependency. If the property changes, MobX efficiently updates only the components that depend on it, ensuring automatic re-renders without manual intervention. This reactive behavior allows for more responsive UI updates based on state changes with less boilerplate code.

Referencing the view model in a React component

/** Return the singleton reference to our example service that was resolved via the ioc container */
export const useExampleService() {
const iocContainer = useIocContainer();
return iocContainer.services.exampleService;
}

// Wrap the component in the MobX "observer" HOC to make it reactive to state changes in the view model.
export const ExampleComponent: React.FC = observer(() => {
const exampleService = useExampleService();
// Create an instance of the view model that will live for the life time of the component
const viewModel = useLocalObservable(() => new ExampleViewModel(exampleService));
// The React component will re-render any time the value of "someValue" changes in the view model.
return (
  <p>
    {viewModel.someValue}
  </p>
)
})

Conclusion

Inversion of Control (IoC) is a key principle for creating flexible, testable, and maintainable React codebases. By decoupling components from their dependencies and using tools like InversifyJS and MobX, we can streamline service management and state updates. This approach not only reduces the boilerplate but also makes components more portable and easier to test. Embracing IoC ensures that your React application stays clean, adaptable, and scalable as it grows.