When I first tried managing authentication in a React app, it quickly turned into a scattered mess of props and local storage lookups sprinkled throughout different components. I had login logic in one part of the app, logout in another, and user data floating in global variables without proper structure. It worked—but it felt brittle. Any refactor risked breaking everything. What I needed was a clean way to centralize authentication logic and make it available anywhere without drilling props or relying on external libraries. That’s when I decided to use the useContext
hook to handle authentication.
Define auth structure
Before anything else, I needed to define what authentication meant for this app. Was it just a token? A user object? A logged-in boolean? I created a simple type structure that reflected what my components would care about.
interface AuthContextType {
user: { id: string; name: string } | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
This gave me clarity. It also ensured that even if I changed the shape of the user object or added new functions later, everything relying on this context would update smoothly. TypeScript immediately became a second pair of eyes watching over future changes.
Set up the context
I didn’t want to deal with weird undefined values or default placeholders, so I followed a strict pattern: initialize with null
and validate it properly. This way, any misuse would be caught early rather than producing cryptic runtime errors.
const AuthContext = React.createContext<AuthContextType | null>(null);
It was tempting to assign an empty object and cast it, but that always came back to haunt me. I’d rather have TypeScript force me to handle edge cases than hide them.
Build the provider
The provider became the heart of the auth logic. This is where I stored the current user, implemented login and logout functions, and controlled the authenticated state. Instead of just dumping everything in localStorage
and moving on, I took the time to sync state with side effects and reflect login persistence accurately.
const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = React.useState<{ id: string; name: string } | null>(null);
const login = async (username: string, password: string) => {
// fake API call
const fetchedUser = { id: '123', name: username };
setUser(fetchedUser);
localStorage.setItem('user', JSON.stringify(fetchedUser));
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
};
React.useEffect(() => {
const stored = localStorage.getItem('user');
if (stored) {
setUser(JSON.parse(stored));
}
}, []);
const isAuthenticated = !!user;
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated }}>
{children}
</AuthContext.Provider>
);
};
Even though this is a simplified example, the pattern scales well. I’ve used it in more robust apps where tokens need refreshing, or user roles determine which parts of the UI to show.
Use a custom hook
I wrapped the context usage in a custom hook to keep my components clean and avoid repetitive null-checking. It also gave me a dedicated place to throw meaningful errors if the hook was used outside the provider.
const useAuth = () => {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Using useAuth()
instead of calling useContext(AuthContext)
directly made the code much easier to read. When building protected routes or conditional rendering logic, I could just call useAuth().isAuthenticated
and everything made sense immediately.
Protecting routes
One of the key places this context paid off was route protection. I created a simple wrapper that checked whether a user was logged in before rendering the route. If not, it redirected to login. This pattern allowed me to keep routing logic declarative and clean.
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? children : <Navigate to="/login" />;
};
It helped me structure public and private routes without writing conditionals in every individual component. That made a huge difference when I added more routes and nested layouts.
Using auth in UI
Finally, using the context in components felt seamless. Whether I was rendering a profile page or just a login button, the same consistent access to user data and login/logout functions kept things tidy.
const Profile = () => {
const { user, logout } = useAuth();
return (
<div>
<p>Welcome, {user?.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
};
I liked how predictable this became. I didn’t have to worry if user
was stale or inconsistent across components. Everything was driven by one source of truth—the auth context.
Reflecting on the pattern
Over time, this approach helped me avoid unnecessary external dependencies for state management or authentication libraries. It gave me just the right level of control, while remaining simple enough to maintain. I’ve reused variations of this setup in apps where API call performance mattered or where dynamic route access was a key requirement.
Authentication always seems deceptively simple until it spreads across the app. Using useContext
and TypeScript together brought that complexity back under control and made it easier to reason about what was happening behind the scenes. More importantly, it turned scattered concerns into a cohesive system—one that was type-safe, testable, and deeply integrated into the app’s architecture.