Logo

dev-resources.site

for different kinds of informations.

Swapable React context without breaking Rules of Hooks and your neck

Published at
1/15/2025
Categories
react
typescript
Author
arekrgw
Categories
2 categories in total
react
open
typescript
open
Author
7 person written this
arekrgw
open
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;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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?

  1. Higher-Order Component (HOC)
  2. Using the useParams hook in the parent component
  3. Render props pattern
  4. Passing a hook as props (?)
  5. 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>;
}
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
      );
    },
  }),
];
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

React Devtools

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Feel free to connect with me on X or LinkedIn.

typescript Article's
30 articles in total
Favicon
Unique Symbols: How to Use Symbols for Type Safety
Favicon
Building bun-tastic: A Fast, High-Performance Static Site Server (OSS)
Favicon
Teaching Large Language Models (LLMs) to do Math Correctly
Favicon
Angular Addicts #33: NgRx 19, using the Page Object Model in tests, Micro Frontends using Vite & more
Favicon
Share the state of a hook between components
Favicon
Swapable React context without breaking Rules of Hooks and your neck
Favicon
Matanuska ADR 010 - Architecture, Revisited
Favicon
Building a Robust Color Mixing Engine: From Theory to Implementation
Favicon
Automating Limit Orders on Polygon with TypeScript, 0x, and Terraform
Favicon
The Magic of useCallback ✨
Favicon
Building a Secure Authentication API with TypeScript, Node.js, and MongoDB
Favicon
"yup" is the new extra virgin olive oil
Favicon
Dynamic Routes in Astro (+load parameters from JSON)
Favicon
TypeScript Discord Bot Handler
Favicon
Form-based Dataverse Web Resources with React, Typescript and FluentUI - Part 2
Favicon
Converting JPA entities to Mendix
Favicon
lodash._merge vs Defu
Favicon
React Native With TypeScript: Everything You Need To Know
Favicon
100 Days of Code
Favicon
Ship mobile apps faster with React-Native-Blossom-UI
Favicon
Import JSON Data in Astro (with Typescript)
Favicon
How to write unit tests and E2E tests for NestJS applications
Favicon
Matanuska ADR 009 - Type Awareness in The Compiler and Runtime
Favicon
How to Build an Image Processor with React and Transformers.js
Favicon
Building an AI-Powered Background Remover with React and Transformers.js
Favicon
Exploring TypeScript Support in Node.js v23.6.0
Favicon
Observing position-change of HTML elements using Intersection Observer
Favicon
Breweries App
Favicon
Using LRU Cache in Node.js and TypeScript
Favicon
Build a Mac Tool to Fix Grammar Using TypeScript, OpenAI API, and Automator

Featured ones: