The moment I began integrating TypeScript into my React projects, the simplicity I enjoyed with useContext
took on a new complexity. Suddenly, it wasn’t just about managing shared state—it became a question of ensuring types were correct, default values made sense, and no component could accidentally consume an undefined context. The challenge wasn’t in understanding what useContext
does, but how to implement it in a way that remained simple, type-safe, and clean.
For smaller apps or isolated features, I needed a way to use context that didn’t feel over-engineered. I didn’t want a huge boilerplate or anything that would make newcomers feel intimidated. I just wanted a reliable pattern—something lightweight that could be reused without much overhead.
Define the types
The first piece that anchored everything was creating a type for the context. I learned quickly that skipping this step led to weak typings and autocomplete that offered no help. For a simple theme toggle, I started with a clear interface.
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
This may look like an extra step, but having these types in place allowed TypeScript to catch issues if the structure ever changed down the road. It also served as documentation for others (or myself later) when revisiting the file.
Create the context
What tripped me up in the beginning was providing a default value. TypeScript doesn’t allow null
unless explicitly defined, so I decided to go with null
and handle it properly, instead of trying to fake a default with a type assertion.
const ThemeContext = React.createContext<ThemeContextType | null>(null);
While it felt a little odd at first, this approach saved me from undefined errors and kept the type strict. I didn’t want a false sense of safety from default objects that didn’t actually do anything.
Build the provider
With the context created, I wrote a basic provider. This component was meant to be simple, without any complex logic. I just wanted it to hold a theme state and provide a toggle function. It turned out surprisingly elegant.
const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
Even at this small scale, I noticed how often it made testing easier. I could wrap only the components I needed without worrying about the entire app context. The function stayed pure, and the interface never drifted from the contract I defined.
Create a custom hook
After getting tired of writing useContext(ThemeContext)
and checking for null each time, I wrapped it in a custom hook. This was one of the smallest changes with the biggest impact. It turned every consumer into a safe one, and every context call became self-documenting.
const useTheme = () => {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
Having this hook turned my components into focused views without noise. I could just call useTheme()
and instantly access everything I needed. This pattern was especially helpful when building features like theme toggles for table layouts or other UI controls.
Consume in a component
Finally, I used the hook in a component. This part felt just like React always has—but now with fully typed values and instant feedback from the editor if anything was off.
const ThemeSwitcher = () => {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
};
Even though this component does something simple, the confidence I had in writing it changed everything. I didn’t second-guess the shape of the context or fear runtime surprises. It just worked—and I knew it would keep working.
The bigger picture
It took me a while to realize that simplicity in TypeScript isn’t about doing less—it’s about being precise with the right things. In this case, a clear interface, a null-safe hook, and a predictable provider brought the useContext
hook back to the simplicity I missed from JavaScript. But now, it came with added confidence.
In larger projects, this exact pattern scales well. Whether I’m building multi-step forms or handling shared layout preferences, this context structure forms a reliable foundation. I’ve reused it across settings pages, language toggles, feature flags, and even user authentication.
TypeScript might demand a little more upfront, but what it gives back in return—readability, maintainability, and bug prevention—has been worth every bit of extra typing. The goal isn’t just to make the app work, but to make sure it keeps working as it grows. And with context set up like this, that goal feels a lot more achievable.