Here, I present the approach I take, it's pretty clean, imho. Here's a Codesandbox link for all the code, down below, each file is linked individually as well.
First, I create a wrapper around the react's createContext
like this -
src/hooks/create-context.ts
import { createContext as reactCreateContext, useContext as reactUseContext } from "react";
export default function createContext<T extends Record<string,unknown>>() {
const context = reactCreateContext<T | undefined>(undefined);
const useContext = (): T => {
const c = reactUseContext(context);
if (c === undefined) {
throw new Error("No Context Found: make sure to use the hook inside the Context Provider children");
}
return c;
}
return [useContext, context.Provider] as const;
}
Then, to make the typings for different context types available, without having to import them manually, create a file index.d.ts
at project root level to house typings, and add your types here to use from, I'm showing a simple counter example using this -
declare namespace Counter {
export type State = { counter: number };
export type Action = { type: "INC_COUNTER" | "DEC_COUNTER" | "RESET_COUNTER", payload: number };
export type Context = {
counterState: State;
counterDispatch: React.Dispatch<Action>
}
}
To make the types from index.d.ts
available in all the places, edit tsconfig.json
to add the ./index.d.ts
path in the include
field -
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src", "./index.d.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
Now, on to creating our counter context, create a file src/hooks/counter-context.tsx
and use the createContext
hook created to create a counter context -
import { useReducer } from "react";
import createContext from "./create-context";
const reducer = (state: Counter.State, action: Counter.Action): Counter.State => {
const copy = { ...state }
switch (action.type) {
case "DEC_COUNTER":
copy.counter -= action.payload;
return copy;
case "INC_COUNTER":
copy.counter += action.payload;
return copy;
default:
return state;
}
}
const initialState: Counter.State = { counter: 0 };
const [_useCounterContext, CounterContextProvider] = createContext<Counter.Context>();
export const useCounterContext = _useCounterContext;
export function CounterContext ({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return <CounterContextProvider value={{ counterState: state, counterDispatch: dispatch }}>
{children}
</CounterContextProvider>
}
From this file, we export the useCounterContext
hook provided by the createContext
and the CounterContext
component that provides access to the context.
After that, use CounterContext
above the component, whose children will be using the context. In my case, I use the context in src/App.tsx
, so we add the CounterContext
in src/main.tsx
, which uses <App />
component as child -
// src/main.tsx file
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { CounterContext } from "./hooks/counter-context";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<CounterContext>
<App />
</CounterContext>
</React.StrictMode>
);
And then at last, we use the context in any child of the <App />
component, or the <App />
component itself, like so -
// src/App.tsx file
import reactLogo from "./assets/react.svg";
import "./App.css";
import { useCounterContext } from "./hooks/counter-context";
function App() {
const {counterState: { counter }, counterDispatch } = useCounterContext();
return (
<div className="App">
<div>
<a href="https://reactjs.org" target="_blank" rel="noreferrer">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
</div>
<h1>React + Vite</h1>
<div className="card">
<h2>Count: {counter}</h2>
<div style={{display: "flex", gap: "1rem"}}>
<button onClick={() => {counterDispatch({type: "INC_COUNTER", payload: 1})}}>Increment</button>
<button onClick={() => {counterDispatch({type: "DEC_COUNTER", payload: 1})}}>Decrement</button>
</div>
</div>
</div>
);
}
export default App;