Deep-Inject 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
fromParentComponent
. 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!