dev-resources.site
for different kinds of informations.
Swapable React context without breaking Rules of Hooks and your neck
I bet that somewhere in your React project codebase, you’ve encountered this type of code:
import { createContext, useContext, useState } from "react";
interface ThemeContext {
theme: string;
setTheme: (theme: string) => void;
}
const ThemeContext = createContext<ThemeContext | null>(null);
interface ThemeContextProviderProps {
children: React.ReactNode;
}
export const ThemeContextProvider = ({ children }: ThemeContextProviderProps) => {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("No ctx found");
return ctx;
};
When using TypeScript, we often assert the context value in the useTheme
hook to avoid TypeScript warnings about potential null
values. However, this pattern hides the ThemeContext
instance itself, which can be crucial in certain scenarios, as we’ll see in this article.
Problem
In one of my projects, I had a table component with sortable columns. Each sorting component needed access to search parameters which was handled by React context up in the tree.
This table component was used across multiple views, some of them had a special ParamsContext
implementation. Example:
import { createContext, ReactNode, useContext, useState } from "react";
interface MostUsedContextType {
params: {
query: string;
page: number;
sortBy?: string;
sortOrder?: string;
};
setParams: (params: MostUsedContextType["params"]) => void;
}
const MostUsedContext = createContext<MostUsedContextType | null>(null);
const MostUsedContextProvider = ({ children }: { children: ReactNode }) => {
const [params, setParams] = useState<MostUsedContextType["params"]>({
// asume that it is from the URL
query: "",
page: 1,
sortBy: undefined,
sortOrder: undefined,
});
return (
<MostUsedContext.Provider value={{ params, setParams }}>
{children}
</MostUsedContext.Provider>
);
};
function useMostUsedContext() {
const ctx = useContext(MostUsedContext);
if (!ctx) throw new Error("No ctx found");
return ctx;
}
// special context
interface SpecialContextType {
params: {
query: string;
page: number;
sortBy?: string;
range: "all" | "week" | "month" | "year";
date?: string;
sortOrder?: string;
};
setParams: (params: SpecialContextType["params"]) => void;
}
const SpecialContext = createContext<SpecialContextType | null>(null);
const SpecialContextProvider = ({ children }: { children: ReactNode }) => {
const [params, _setParams] = useState<SpecialContextType["params"]>({
// asume that it is from the URL
query: "",
range: "all",
date: undefined,
page: 1,
sortBy: undefined,
sortOrder: undefined,
});
const setParams = (params: SpecialContextType["params"]) => {
const newParams = {
...params,
};
// some aditional logic
_setParams(newParams);
};
// more special logic
return (
<SpecialContext.Provider value={{ params, setParams }}>
{children}
</SpecialContext.Provider>
);
};
function useSpecialContext() {
const ctx = useContext(SpecialContext);
if (!ctx) throw new Error("No ctx found");
return ctx;
}
In example below you can see that one context is just handling params and the other one is doing more business logic. Using useMostUsedContext
in mentioned sorting component will limit it's flexibility. So I had to find more flexible approach:
What are the options?
- Higher-Order Component (HOC)
- Using the
useParams
hook in the parent component - Render props pattern
- Passing a hook as props (?)
- Passing the context instance as prop
Let’s discuss each approach with examples. First, we define a base ParamsContext
and Sorting
component so we can work on something together:
interface ParamsContextType {
params: {
sortBy?: string;
sortOrder?: string;
page?: number;
pageSize?: number;
search?: string;
};
setParams: (params: ParamsContextType["params"]) => void;
}
const ParamsContext = createContext<ParamsContextType | null>(null);
interface ParamsContextProviderProps {
children: ReactNode;
}
export const ParamsContextProvider = ({
children,
}: ParamsContextProviderProps) => {
const [params, setParams] = useState<ParamsContextType["params"]>({
sortBy: undefined,
sortOrder: undefined,
});
return (
<ParamsContext.Provider value={{ params, setParams }}>
{children}
</ParamsContext.Provider>
);
};
export const useParams = () => {
const ctx = useContext(ParamsContext);
if (!ctx) throw new Error("No ctx found");
return ctx;
};
// Sorting.tsx
function Sorting({
params,
setParams,
sortKey,
}: {
params: {
sortBy?: string;
sortOrder?: string;
};
setParams: (params: { sortBy?: string; sortOrder?: string }) => void;
sortKey: string;
}) {
return <div>Sorting</div>;
}
Higher-Order Component (HOC)
A classic way to handle this scenario is to create a wrapWithContext
function. For each variation of the ParamsProvider
, you create a separate component wrapped with the specific context:
function wrapWithContext<
T extends Record<PropertyKey, any>,
P extends Record<PropertyKey, any>
>(InjectableContext: Context<T | null>, Component: ComponentType<P>) {
return (props: Omit<ComponentPropsWithoutRef<typeof Component>, keyof T>) => {
const ctx = useContext(InjectableContext); // use(InjectableContext)
if (!ctx) throw new Error("No ctx found");
const p = {
...props,
...ctx,
} as unknown as P;
return <Component {...p} />;
};
}
const SortingWithParams = wrapWithContext(ParamsContext, Sorting);
export default function View() {
return (
<ParamsContextProvider>
<SortingWithParams sortKey="firstName" />
</ParamsContextProvider>
);
}
This approach is flexible — you can pass any context instance to the HOC. However, it can lead to excessive component variables as your project grows, creating unnecessary duplication.
Using the useParams
Hook in the Parent
This one is simple. In parent component where you render Sorting
component, just use useParams
hook and pass all required props to Sorting
component. There are cases where it is not possible, for example in render functions in which you cannot invoke hooks.
function View2() {
const ctx = useParams();
return (
<Sorting
params={ctx.params}
setParams={ctx.setParams}
sortKey="firstName"
/>
);
}
function Providers() {
return (
<ParamsContextProvider>
<View2 />
</ParamsContextProvider>
);
}
// -------------------------------------
import { createColumnHelper } from "@tanstack/react-table";
const helper = createColumnHelper<SomeType>();
const defaultColumns = [
helper.display({
id: "firstName",
cell: (props) => {
return <Text>{props.row.original.firstName}</Text>;
},
header: () => {
const ctx = useParams(); // ❌ this is not a component -> invalid hook call!
return (
<Sorting
params={ctx.params}
setParams={ctx.setParams}
sortKey="firstName"
>
First Name
</Sorting>
);
},
}),
];
In the end it's not that flexible, you cannot use useParams
hook in the same component where you render provider and in the second example with table you have to create wrapper component to fix that hook call (or use other approaches from this article).
Render Props Pattern
The render props pattern introduces a flexible and reusable way to inject context:
const InjectParamsContext = ({
children,
}: {
children: (ctx: ParamsContextType) => ReactNode;
}) => {
const ctx = useParams();
return children(ctx);
};
// or
const InjectContext = <T,>({
Context,
children,
}: {
Context: Context<T>;
children: (ctx: NonNullable<T>) => ReactNode;
}) => {
const ctx = useContext(Context);
if (!ctx) throw new Error("No ctx found");
return children(ctx);
};
function View3() {
return (
<InjectContext Context={ParamsContext}>
{(ctx) => (
<Sorting
params={ctx.params}
setParams={ctx.setParams}
sortKey="firstName"
/>
)}
</InjectContext>
);
}
As you can see we create a dedicated or generic "injector". What's important in this example is that you might find it similar to using just a hook, but there is one big difference, components with render props are swappable, you could just make a ternary oprator in View3
component and pass different context instance to injector or use different dedicated injector. For new React devs it might look a bit too verbose at first but many libraries use render props pattern with success (Ark UI). To sum up, it is very flexible, you can use it in column header from above example. However you have to write a lot of code and the syntax is not that easy to write with your fingers.
Passing hook as props (?)
A tempting idea could be to just pass a hook as a prop to component that resolves context... but it breaks Rules of Hooks and ESlint will complain. The main reason is that those are just props which can change over time therefore you would get an error like "Conditionally rendered hook" which is forbidden in this world. You could get out with this if this prop never change and mark it as readonly or something in TypeScript:
/* eslint-disable react-compiler/react-compiler */
import { createContext, ReactNode, useContext } from "react";
const SomeContext = createContext<{ val: number; val2: string } | null>(null);
const Provider1 = ({ children }: { children: ReactNode }) => {
return (
<SomeContext
value={{
val: 1,
val2: "random",
}}
>
{children}
</SomeContext>
);
};
const use1Context = () => {
const ctx = useContext(SomeContext);
if (!ctx) throw new Error("No ctx found");
return ctx;
};
export default function MyApp() {
return (
<Provider1>
<Consumer useInjectedContext={use1Context} />
</Provider1>
);
}
export const Consumer = ({
useInjectedContext,
}: {
useInjectedContext: () => { val: number; val2: string };
}) => {
const { val, val2 } = useInjectedContext();
return (
<>
{val}
{val2}
</>
);
};
It is flexible enough for me but still quite risky, even though, according to the React devtools, this Consumer
component was optimized by the React Compiler (version 19.0.0-beta-63e3235-20250105
)! Proof is provided in the screenshot below.
You can see that MyApp
wasn't optimized but I think there is just nothing to optimize and I tested it without the hook as props injection and it was still not optimized. So as long as this passed hook stays always the same, I'm okay with it.
Passing context instance as props
Now the last option and one that I finally used with that project I mentioned above. You prepare a special useContext
hook that will check for Provider existance based on provided context instance in parameter to get rid of null
value and the consumer component will use it, not a wrapper or parent!:
// special useContext wrapper hook
function useSafeContext<T>(Context: Context<T>) {
const ctx = useContext(Context);
if (!ctx) throw new Error("No ctx found");
return ctx;
}
// we modify the Sorting component to use the new
// hook or we can just create a onetime wrapper
// for it
function SortingImproved({
sortKey,
Context,
}: {
sortKey: string;
Context: Context<{
params: { sortBy?: string; sortOrder?: string };
setParams: (params: { sortBy?: string; sortOrder?: string }) => void;
} | null>;
}) {
const ctx = useSafeContext(Context);
return <div>Sorting</div>;
}
// just pass the context that meet component type constraints
function View4() {
return (
<ParamsContextProvider>
<SortingImproved sortKey="firstName" Context={ParamsContext} />
</ParamsContextProvider>
);
}
In my opinion, it is a very powerful pattern, though mainly suited for niche use cases. Nevertheless, it is worth knowing. It is also React Compiler-safe, and, what's more, you can utilize the new use
function from React 19. This function can replace the useContext
hook in all cases and has a significant advantage: it can be rendered conditionally. In the future, it may even be used within the useMemo
hook to prevent unnecessary re-renders, which often occur with React contexts.
Conclusion
React context offers a lot of flexibility, especially when combined with the patterns discussed. However, be cautious of performance bottlenecks caused by frequent re-renders. For complex state management needs, consider alternatives like TanStack Store or Zustand.
Everything here was tested on React@19
Thanks for reading! I hope you found this article helpful.
Featured ones: