Prop drilling is usually the reason we reach for React Context: state that started in one component ends up threaded through five layers of props, and every layer in between has to know about data it doesn't use. Context fixes that. But "use Context" isn't a setup — there are a dozen ways to structure the files, and most of them share a papercut that has annoyed me in every codebase I've worked in.
This article walks through the setup I've landed on. It's three files, it makes "Go to Definition" actually useful, and it uses a lint rule to make the one risky part of the pattern impossible to get wrong. The running example is a tic-tac-toe game — the full working project is at react-context-boilerplate-example.
The papercut
Here's the workflow friction. In a typical Context setup, components access state through a hook:
const { winner, resetGame } = useGame();
You're reading a component, you want to know how resetGame actually works, so you Command + Click on useGame. And you land... in a file that contains a useContext call and nothing else. The actual state management — the useState calls, the update logic — lives inside the provider component in a different file. So you go searching for it.
Every. Single. Time.
The setup below fixes that: Go to Definition on the consumer hook lands in the same file as all the state-management logic.
The structure
Three pieces, two files:
src/context/
├── GameContext.ts ← the context, the public hook, AND the state logic
└── GameProvider.tsx ← a thin shell
The trick is that the state management isn't written inside the provider component. It's written as a hook, in the same file as the consumer hook.
Step 1: The context file
GameContext.ts contains three things: the context object, the public hook that components use, and a private hook holding all the state logic.
import { createContext, useContext, useState } from 'react';
import type { GameContextI, Winner, BoardContent, Player } from '../types';
import {
INITIAL_BOARD_CONTENT,
INITIAL_CURR_PLAYER,
INITIAL_WINNER,
} from '../constants/gameConstants';
export const GameContext = createContext<GameContextI | null>(null);
// PUBLIC: components call this to access the game state.
export function useGame() {
const context = useContext(GameContext);
if (!context) {
throw new Error('useGame must be used within a GameProvider');
}
return context;
}
// PRIVATE: only GameProvider should ever call this.
export function useGameStateManagement() {
const [currPlayer, setCurrPlayer] = useState<Player>(INITIAL_CURR_PLAYER);
const [winner, setWinner] = useState<Winner>(INITIAL_WINNER);
const [boardContent, setBoardContent] = useState<BoardContent>(
INITIAL_BOARD_CONTENT,
);
const updateSquareContent = (
rowIndex: number,
colIndex: number,
value: Player,
) => {
// ...update the board, check for a winner, advance the turn...
};
const resetGame = () => {
setCurrPlayer(INITIAL_CURR_PLAYER);
setWinner(INITIAL_WINNER);
setBoardContent(INITIAL_BOARD_CONTENT);
};
return {
winner,
currPlayer,
setCurrPlayer,
boardContent,
setBoardContent,
updateSquareContent,
resetGame,
};
}
A few things to notice:
-
The context is typed as
GameContextI | nulland the public hook throws if the context is missing. That null check happens once, here — components never have to handle a possibly-null context, and a component rendered outside the provider fails loudly with a message that says exactly what's wrong. -
useGameStateManagementis a real hook, so it can useuseState(oruseReducer, or anything else) exactly as if the logic were written inline in the provider. -
Both hooks are in one file. Go to Definition on
useGamefrom any component takes you here — where the state logic lives. Papercut solved.
Step 2: The thin provider
Because the logic lives in the hook, the provider has almost nothing to do:
import React from 'react';
import { GameContext, useGameStateManagement } from './GameContext';
interface GameProviderProps {
children?: React.ReactNode;
}
export default function GameProvider({ children }: GameProviderProps) {
const contextValue = useGameStateManagement();
return (
<GameContext.Provider value={contextValue}>{children}</GameContext.Provider>
);
}
Step 3: Wrap the tree and consume
Wrap the part of the tree that needs the state (here, the whole app):
createRoot(document.getElementById('root')!).render(
<StrictMode>
<GameProvider>
<App />
</GameProvider>
</StrictMode>,
);
And any component underneath just calls the public hook:
import { useGame } from './context/GameContext';
function App() {
const { winner, resetGame } = useGame();
// ...
}
No prop drilling, no null checks, and jumping to useGame's definition drops you next to the actual logic.
The catch
There's one problem with this pattern, and it's the price of the co-location: useGameStateManagement has to be exported so the provider can import it. Which means it looks importable to everyone.
If a teammate skims the file, sees an exported hook that returns the entire game state, and calls it from a component — the code will compile, render, and even sort of work. But that component gets its own, brand-new copy of the state, completely detached from the context value every other component shares. Clicking a square updates one board; the rest of the app is looking at a different one. It's the kind of bug that costs an afternoon.
A // do not call this outside GameProvider comment is a speed bump, not a wall. What I wanted was a lint error.
Enforcing it with a lint rule
I packaged this up as eslint-plugin-restrict-callers: an ESLint rule that restricts which named functions may call a given function.
npm install --save-dev eslint-plugin-restrict-callers
Then in eslint.config.js (flat config, ESLint 9+):
import restrictCallers from 'eslint-plugin-restrict-callers';
export default [
{
plugins: { 'restrict-callers': restrictCallers },
rules: {
'restrict-callers/restrict-callers': ['error', {
restrictedFunctions: [
{
functionName: 'useGameStateManagement',
allowedCallers: ['GameProvider'],
alternative: 'useGame',
},
],
}],
},
},
];
Now calling the private hook anywhere other than GameProvider fails lint with a message that tells the reader what to do instead:
error useGameStateManagement may only be called inside GameProvider. Use useGame instead
Each entry takes a functionName (the function to protect), allowedCallers (the functions permitted to call it), and optionally an alternative to suggest — or a fully custom message. It isn't hook-specific, either: you can use it any time a function has to be exported but is only meant for one caller. And because it's just config, every new context you add is one more entry in the array.
Recap
- Put the context, the public consumer hook, and the private state-management hook in one file — Go to Definition on the consumer hook lands on the logic.
- Keep the provider a thin shell that calls the private hook and passes the result to
Context.Provider. - Throw from the public hook when the context is null, so misplaced components fail loudly.
- Enforce the public/private boundary with eslint-plugin-restrict-callers instead of a comment.
The full working example — game and all — is at react-context-boilerplate-example, and the plugin lives at eslint-plugin-restrict-callers. If you've felt this papercut too, I'd love to hear how you've handled it.
Top comments (1)
I've struggled with prop drilling in larger React apps, how do you handle cases where Context state needs to be updated from a deeply nested component? I'd love to hear more about your approach.