Practical Case: Session and Notifications with Zustand
Now we apply everything to the week 12 project. You have a login that stores the token in localStorage through session.ts, an AuthGuard and a dashboard. In this practice:
- We migrate the session to a Zustand store with
persist, so any component can react to login and logout. - We build a notification system that is emitted from one screen and read in the navigation bar, without passing
props. - We verify with Redux DevTools what happens on each change.
Work on your week 12 project. You must have LoginForm, RegisterForm, AuthGuard, apiFetch and the (app) layout working.
1. What We Are Going to Change
| File (week 12) | What happens in week 13 |
|---|---|
shared/lib/session.ts | Replaced by shared/store/session.store.ts |
shared/lib/api.ts | Reads the token from the store with getState() |
features/auth/services/auth.service.ts | Saves and deletes the token in the store |
features/auth/components/AuthGuard.tsx | Waits for the session to be restored and reads the store with a selector |
app/(app)/layout.tsx | Restores the session and shows the notification bell |
(new) features/notifications/... | Notification store, creator and bell |
2. Step by Step
Install Zustand
npm install zustand
Create the session store
Create src/shared/store/session.store.ts. It only keeps the token and a flag to know whether the session has already been restored from localStorage.
Connect the HTTP client and the authentication service
api.ts and auth.service.ts read and write the token in the store.
Update AuthGuard and the layout
The guard waits for the restore; the layout triggers it once.
Create the notifications store and its components
Reuse the store from lesson 3.
Emit notifications from the login
Notify when the session is started and closed, and look at the result in Redux DevTools.
3. The Session Store
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
type SessionState = {
token: string | null;
hasHydrated: boolean;
setToken: (token: string) => void;
clearSession: () => void;
setHasHydrated: (value: boolean) => void;
};
export const useSessionStore = create<SessionState>()(
devtools(
persist(
(set) => ({
token: null,
hasHydrated: false,
setToken: (token) => set({ token }, undefined, 'session/setToken'),
clearSession: () => set({ token: null }, undefined, 'session/clear'),
setHasHydrated: (value) => set({ hasHydrated: value }, undefined, 'session/hydrated'),
}),
{
name: 'auth_token', // same key as week 12
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token }),
skipHydration: true, // we restore it ourselves, after the first render
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
},
),
{ name: 'SessionStore', enabled: process.env.NODE_ENV !== 'production' },
),
);
What decisions are here, and which lesson they come from:
partializeonly savestoken.hasHydratedis browsing-session data, not something that should persist.skipHydrationavoids the hydration mismatch we discussed in lesson 4.onRehydrateStoragetells us when it has finished restoring, so we can turn onhasHydrated.- Action names (third argument of
set) to read the history in Redux DevTools.
persist saves a JSON object ({"state":{"token":"..."},"version":0}), whereas session.ts saved the token as plain text under auth_token. If your browser keeps a week 12 session, delete that key (localStorage.removeItem('auth_token') in the console) or log in again.
4. Connect the HTTP Client and the Service
api.ts no longer imports session. It reads the token with getState(), because it is not a component:
import { useSessionStore } from '@/shared/store/session.store';
export async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = useSessionStore.getState().token;
// ... the rest stays the same as in week 12
}
The authentication service writes to the store instead of localStorage:
import { useSessionStore } from '@/shared/store/session.store';
export const authService = {
async login(credentials: LoginCredentials): Promise<string> {
const token = await apiFetch<string>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
useSessionStore.getState().setToken(token);
return token;
},
logout(): void {
useSessionStore.getState().clearSession();
},
// register() does not change
};
authService and apiFetch are normal functions, not components. Hooks can only be called inside components, while getState() works in any file.
5. AuthGuard and Layout
The guard no longer consults localStorage. It uses two selectors and a useEffect to decide:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSessionStore } from '@/shared/store/session.store';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const hasHydrated = useSessionStore((s) => s.hasHydrated);
const isAuthenticated = useSessionStore((s) => s.token !== null);
useEffect(() => {
if (hasHydrated && !isAuthenticated) {
router.replace('/login');
}
}, [hasHydrated, isAuthenticated, router]);
if (!hasHydrated) {
return <p className="p-8">Verifying access authorization...</p>;
}
if (!isAuthenticated) return null;
return <>{children}</>;
}
Notice what we gain: if the token is deleted anywhere (a logout, a backend 401), the guard finds out on its own and redirects. In week 12 it only checked once, when mounting.
The layout restores the session once and shows the bell:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { authService } from '@/features/auth/services/auth.service';
import { AuthGuard } from '@/features/auth/components/AuthGuard';
import { NotificationBell } from '@/features/notifications/components/NotificationBell';
import { useSessionStore } from '@/shared/store/session.store';
export default function AppLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
// Restore the saved session, only once when mounting
useEffect(() => {
useSessionStore.persist.rehydrate();
}, []);
const handleLogout = () => {
authService.logout();
router.replace('/login');
};
return (
<AuthGuard>
<header className="navbar border-b border-base-200 px-4">
<div className="flex-1" />
<NotificationBell />
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
Log out
</button>
</header>
<main className="p-8">{children}</main>
</AuthGuard>
);
}
rehydrate() is called in the layout, not inside AuthGuard, so that it happens only once per page load and not every time the guard mounts. If the login page also needs to know whether there is already a session (to skip the form), apply the same pattern in app/(auth)/layout.tsx.
6. Notifications
Create the store with the code from lesson 3 in features/notifications/store/notifications.store.ts, and the NotificationCreator and NotificationBell components from section 4 of that lesson. With that, NotificationBell already updates when someone emits a notification.
Emit from the Login
Outside React the action is called with getState(). Add this in auth.service.ts:
import { useNotificationsStore } from '@/features/notifications/store/notifications.store';
// inside login(), after saving the token:
useNotificationsStore.getState().addNotification('Login successful');
// inside logout():
useNotificationsStore.getState().addNotification('Session closed');
Now login and logout notify the navigation bar without any component passing them along. That is the whole connection.
7. Verify with Redux DevTools
Install the Redux DevTools extension and open the tab on the dashboard page. Check:
- When the page loads,
session/hydratedappears: the session was restored. - When you log in, you see
session/setTokenwith the token in the state. - When you log out, you see
session/clear. - With the Diff tab, confirm that each action changed only what it should.
8. Delivery Criteria
Functional
Login saves the session in the store; reloading the page keeps it; logging out deletes it and redirects to /login.
Structure
Each store in its folder (shared/store or features/<x>/store), with types separated into data and actions.
Performance
All components use selectors; NotificationCreator does not re-render when a notification arrives.
Evidence
Screenshot of Redux DevTools with the session/hydrated, session/setToken and session/clear actions.
Optional Challenges
- Mark as read: add a dropdown menu with the notification list and the
markReadandmarkAllReadbuttons. - Persist notifications: apply
persistwithpartializeto the notifications store and decide whether they should survive logout (hint:subscribeandsetStatefrom lesson 4). - Light and dark theme: create
shared/store/ui.store.tswith the theme and atoggleThemeaction. - Respond to 401: if
apiFetchreceives a401, callclearSession()and let theAuthGuardkick the user out.