mykeels.com

Deep-Inject Dependencies into React components

It's so easy, I'm surprised I didn't figure it out before.

Deep-Inject Dependencies into React components

Injecting dependencies into React components

I've struggled with dependency injection in React components for a while. Every attempt seemed to create more complexity than it solved. Yesterday, I finally discovered a clean, simple solution that I wish I'd found sooner.

The Problem

Consider a React component that fetches data from an API. I want to inject this dependency through props so I can easily mock it in Storybook stories:

type MyComponentProps = {
  getItems: () => Promise<Item[]>;
}

const MyComponent = ({ getItems }: MyComponentProps) => {
  const { data: items } = useQuery({
    queryKey: ['items'],
    queryFn: getItems,
  })
  return <div>{items?.map((item) => <div key={item.id}>{item.name}</div>)}</div>;
};

My typical approach is to provide a default implementation that makes the actual API call:

const MyComponent = ({
    getItems = apiService.getItems,
}: MyComponentProps) => { ... }

This works well because the component uses the real API by default, but I can override it in stories for testing different scenarios e.g.

export const Index = () => (<MyComponent getItems={async () => [{ id: 1, name: 'Item 1' }]} />);

export const Empty = () => (<MyComponent getItems={async () => []} />);

export const Error = () => (<MyComponent getItems={async () => { throw new Error('Error') }} />);

export const Loading = () => (<MyComponent getItems={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)) }} />);

This allows me to test various scenarios and edge cases for the component.

But what happens when MyComponent is used inside a ParentComponent? How do I pass the dependency down?

I could pass getItems as a prop to ParentComponent and then relay it to MyComponent, but that is prop-drilling, which is something I've discussed avoiding in React in this tweet thread on Interface Segregation in React.

So how can I inject the dependency into MyComponent without prop-drilling?

In the above tweet thread, I suggested using the inversion of control pattern without a container, but my abstracting away MyComponent from ParentComponent. It was a bit of a brain twister, but it worked. I've played around with using IoC libraries in React, but they always felt like overkill. Sometimes, I just want something very simple.

The Solution

What if I could do this instead:

const MyComponent = ({ 
    getItems = ioc(keys => keys.getItems, apiService.getItems) 
}: MyComponentProps) => { ... }

The ioc(keys => keys.getItems, apiService.getItems) function would return either:

  • The dependency from the IoC container if one is registered
  • The apiService.getItems fallback if no override is specified

The implementation can be registered at runtime using:

ioc.add(keys => keys.getItems, async () => [{ id: 1, name: 'Item 1' }]);

This registration can happen at any point during runtime, before MyComponent is rendered.

How would we implement this IoC container? It's surprisingly simple—no external library needed.

The Implementation

type IocValue = () => Promise<unknown>;

const IocKeys = {
    getItems: 'getItems',
    // add other keys here
} as const;

const IocValues = {} as Record<keyof typeof IocKeys, IocValue>;

export function ioc<TValue>(
    getKey: (keys: typeof IocKeys) => keyof typeof IocKeys
): (() => Promise<TValue>) | undefined;

// overload for the default implementation
export function ioc<TValue>(
    getKey: (keys: typeof IocKeys) => keyof typeof IocKeys,
    defaultImplementation?: () => Promise<TValue>
) {
    const key = getKey(IocKeys);
    const override = IocValues[key as keyof typeof IocValues] as (() => Promise<TValue>) | undefined;
    return override || defaultImplementation;
}

const add = (
    getKey: (keys: typeof IocKeys) => keyof typeof IocKeys,
    value: IocValue
) => {
    IocValues[getKey(IocKeys)] = value;
    return ioc;
}

ioc.add = add;
ioc.keys = IocKeys;

That's it! Now we can use ioc to inject dependencies into our components:

type Item = {
    id: number;
    name: string;
};
type MyComponentProps = {
  getItems: () => Promise<Item[]>;
}

declare function useQuery<TData>(
  props: { 
    queryFn: () => Promise<TData>, 
    queryKey: string[] 
  }
): { data: TData };

const apiService = {
    getItems: async () => [] as Item[]
}

const MyComponent = ({
    getItems = ioc(keys => keys.getItems, apiService.getItems)
}: MyComponentProps) => {
    const { data: items } = useQuery({
        queryKey: ['items'],
        queryFn: getItems,
    })
    return <div>{items?.map((item) => <div key={item.id}>{item.name}</div>)}</div>;
}

Now let's say we have a ParentComponent that uses MyComponent:

const ParentComponent = () => {
    return <MyComponent />;
}

We can use ioc to inject the dependency in our Storybook stories:

export const Index = () => {
    ioc.add(keys => keys.getItems, async () => [{ id: 1, name: 'Item 1' }]);
    return <ParentComponent />;
};

export const Empty = () => {
    ioc.add(keys => keys.getItems, async () => []);
    return <ParentComponent />;
};

I have put together this TypeScript Playground Link so you can check out the types and play around with them.

Limitations

This approach isn't perfect for every scenario. If MyComponent is used multiple times within ParentComponent and needs different dependencies for each instance, this solution falls short.

However, for most use cases—especially when you need to inject the same dependency across multiple components in a tree—this simple IoC pattern provides an elegant solution without the complexity of prop-drilling or heavy dependency injection libraries.

This simple pattern has made my React components much more testable and maintainable. Sometimes the best solutions are the simplest ones.

Happy coding!

Related Articles

Tags