Overview
This portfolio uses Zustand for client-side state management. Zustand is a small, fast, and scalable state management solution that works seamlessly with React 19 and Next.js 16 Server Components.
Why Zustand? It provides a simple API with minimal boilerplate, no Context providers needed, and excellent TypeScript support—perfect for managing client preferences and UI state.
Installation
Zustand is already installed in the project:
Current Implementation
Language Store
The portfolio implements a language preference store that persists user’s language choice (Portuguese or English) to localStorage.
src/store/language-store.ts
"use client" ;
import { create } from "zustand" ;
export type Lang = "ptBR" | "en" ;
type LanguageState = {
lang : Lang ;
hydrated : boolean ;
hydrate : () => void ;
setLang : ( lang : Lang ) => void ;
};
const KEY = "portfolio-language" ;
export const useLanguageStore = create < LanguageState >(( set , get ) => ({
lang: "ptBR" ,
hydrated: false ,
hydrate : () => {
if ( get (). hydrated ) return ;
const stored = ( localStorage . getItem ( KEY ) as Lang | null ) ?? "ptBR" ;
set ({ lang: stored , hydrated: true });
},
setLang : ( lang ) => {
localStorage . setItem ( KEY , lang );
set ({ lang });
}
}));
File Location: src/store/language-store.ts
Store Architecture Breakdown
Type Definitions
export type Lang = "ptBR" | "en" ;
type LanguageState = {
lang : Lang ; // Current language
hydrated : boolean ; // Whether state is loaded from localStorage
hydrate : () => void ; // Load from localStorage
setLang : ( lang : Lang ) => void ; // Update language
};
Lang: Type-safe language options
LanguageState: Complete store shape with actions
hydrated: Prevents hydration mismatches in SSR
Store Creation
export const useLanguageStore = create < LanguageState >(( set , get ) => ({
// Initial state
lang: "ptBR" ,
hydrated: false ,
// Actions
hydrate : () => { /* ... */ },
setLang : ( lang ) => { /* ... */ }
}));
create(): Zustand store factory
set(): Update state immutably
get(): Access current state
Type parameter ensures full type safety
Hydration Pattern
hydrate : () => {
if ( get (). hydrated ) return ; // Only hydrate once
const stored = ( localStorage . getItem ( KEY ) as Lang | null ) ?? "ptBR" ;
set ({ lang: stored , hydrated: true });
}
Why Hydration?
Next.js renders on server (no localStorage access)
Client needs to sync with persisted state
Prevents hydration mismatch errors
Called once on client mount
Persistence
setLang : ( lang ) => {
localStorage . setItem ( KEY , lang ); // Persist first
set ({ lang }); // Then update state
}
Updates localStorage synchronously
Triggers re-render with new state
Survives page refreshes
Usage in Components
Hydration Hook
To prevent hydration mismatches, we use a custom hook to initialize the store on the client:
src/app/hooks/use-language-hydrate.ts
"use client" ;
import { useLanguageStore } from "@/store/language-store" ;
import { useEffect } from "react" ;
export function useLanguageHydrate () {
const hydrate = useLanguageStore (( s ) => s . hydrate );
useEffect (() => hydrate (), [ hydrate ]);
}
How It Works:
Extracts hydrate function from store
Runs on client mount (useEffect)
Loads language from localStorage
Sets hydrated: true to prevent re-hydration
Reading State
Component Reading Language
Selecting Multiple Values
"use client" ;
import { useLanguageStore } from "@/store/language-store" ;
import { useLanguageHydrate } from "@/app/hooks/use-language-hydrate" ;
export function MyComponent () {
// Initialize store on mount
useLanguageHydrate ();
// Read current language
const lang = useLanguageStore (( state ) => state . lang );
return < div >{ lang === "en" ? "Hello" : "Olá" } </ div > ;
}
Selector Pattern: Only subscribe to the parts of state you need. This prevents unnecessary re-renders when other parts of the store update.
Updating State
src/components/select-language.tsx
"use client" ;
import { useLanguageStore } from "@/store/language-store" ;
export function SelectLanguage () {
const { lang , setLang } = useLanguageStore (( state ) => ({
lang: state . lang ,
setLang: state . setLang
}));
return (
< div >
< button
onClick = {() => setLang ( "en" )}
className = { lang === "en" ? "active" : "" }
>
English
</ button >
< button
onClick = {() => setLang ( "ptBR" )}
className = { lang === "ptBR" ? "active" : "" }
>
Português
</ button >
</ div >
);
}
Flow:
User clicks button
setLang("en") is called
Language saved to localStorage
State updated in Zustand store
All subscribed components re-render
Translation Helper
The language store integrates with a simple translation utility:
src/utils/i18n.ts
Usage in Components
type Lang = "ptBR" | "en" ;
type I18nText = string | { ptBR : string ; en : string };
export function translate ( value : I18nText , lang : Lang ) {
if ( typeof value === "string" ) return value ;
return value [ lang ] ?? value . ptBR ;
}
Creating New Stores
Template for New Store
src/store/theme-store.ts
src/store/ui-store.ts
"use client" ;
import { create } from "zustand" ;
type Theme = "light" | "dark" | "system" ;
type ThemeState = {
theme : Theme ;
setTheme : ( theme : Theme ) => void ;
};
export const useThemeStore = create < ThemeState >(( set ) => ({
theme: "system" ,
setTheme : ( theme ) => {
localStorage . setItem ( "theme" , theme );
set ({ theme });
// Apply theme to document
document . documentElement . classList . toggle ( "dark" , theme === "dark" );
}
}));
Store Best Practices
1. TypeScript First
Always define types for state and actions: type MyState = {
value : string ;
updateValue : ( value : string ) => void ;
};
export const useMyStore = create < MyState >(( set ) => ({ ... }));
2. Client-Only Directive
Always add "use client" at the top of store files: "use client" ;
import { create } from "zustand" ;
3. Single Responsibility
Each store should manage one domain:
✅ useLanguageStore - Language preferences
✅ useThemeStore - Theme settings
✅ useUIStore - UI state (modals, sidebars)
❌ useAppStore - Everything (too broad)
4. Immutable Updates
Use set() with object spreading: // ✅ Good
set (( state ) => ({ count: state . count + 1 }))
// ❌ Bad (mutates state)
set (( state ) => {
state . count ++ ;
return state ;
})
5. Selective Subscriptions
Subscribe to only what you need: // ✅ Good - only re-renders when lang changes
const lang = useLanguageStore (( s ) => s . lang );
// ❌ Bad - re-renders on any state change
const store = useLanguageStore ();
const lang = store . lang ;
Advanced Patterns
Middleware: Persist
Zustand supports middleware for advanced features:
import { create } from "zustand" ;
import { persist , createJSONStorage } from "zustand/middleware" ;
type LanguageState = {
lang : Lang ;
setLang : ( lang : Lang ) => void ;
};
export const useLanguageStore = create < LanguageState >()(
persist (
( set ) => ({
lang: "ptBR" ,
setLang : ( lang ) => set ({ lang })
}),
{
name: "portfolio-language" , // localStorage key
storage: createJSONStorage (() => localStorage )
}
)
);
The current implementation uses manual localStorage for more control, but the persist middleware is a good alternative for simpler cases.
Computed Values with Selectors
Store with Computed Values
import { create } from "zustand" ;
type CartState = {
items : Array <{ id : string ; price : number ; quantity : number }>;
addItem : ( item : CartState [ "items" ][ 0 ]) => void ;
};
export const useCartStore = create < CartState >(( set ) => ({
items: [],
addItem : ( item ) => set (( state ) => ({
items: [ ... state . items , item ]
}))
}));
// Computed selector (memoized)
export const selectTotal = ( state : CartState ) =>
state . items . reduce (( sum , item ) => sum + item . price * item . quantity , 0 );
// Usage
const total = useCartStore ( selectTotal );
Async Actions
import { create } from "zustand" ;
type UserState = {
user : User | null ;
loading : boolean ;
error : string | null ;
fetchUser : ( id : string ) => Promise < void >;
};
export const useUserStore = create < UserState >(( set ) => ({
user: null ,
loading: false ,
error: null ,
fetchUser : async ( id ) => {
set ({ loading: true , error: null });
try {
const response = await fetch ( `/api/users/ ${ id } ` );
const user = await response . json ();
set ({ user , loading: false });
} catch ( error ) {
set ({ error: error . message , loading: false });
}
}
}));
When to Use State vs Props vs Context
Use Zustand State When:
✅ Global client state (theme, language, user preferences)
✅ Shared across many components
✅ Needs persistence (localStorage)
✅ Frequent updates from many places
✅ Complex state logic
Use Props When:
✅ Parent-to-child data flow
✅ Component-specific data
✅ Single source of truth in parent
✅ Server Component data (Next.js)
Use React Context When:
✅ Deeply nested component trees (theme provider)
✅ Rarely changing data
✅ Dependency injection pattern
❌ Frequently updating data (use Zustand instead)
Use Server State Libraries (SWR/React Query) When:
✅ Fetching data from APIs
✅ Need caching, revalidation, optimistic updates
✅ Synchronizing server state
❌ Currently not needed in this portfolio (static content)
Re-render Optimization
// ✅ Good - Only re-renders when lang changes
const lang = useLanguageStore (( s ) => s . lang );
// ✅ Good - Only re-renders when setLang reference changes (never)
const setLang = useLanguageStore (( s ) => s . setLang );
// ❌ Bad - Re-renders on any state change
const { lang , hydrated , setLang } = useLanguageStore ();
// ✅ Better - Use shallow comparison
import { shallow } from "zustand/shallow" ;
const { lang , hydrated } = useLanguageStore (
( s ) => ({ lang: s . lang , hydrated: s . hydrated }),
shallow
);
import { create } from "zustand" ;
import { devtools } from "zustand/middleware" ;
export const useLanguageStore = create < LanguageState >()(
devtools (
( set ) => ({
lang: "ptBR" ,
setLang : ( lang ) => set ({ lang }, false , "setLang" )
}),
{ name: "LanguageStore" }
)
);
Install Redux DevTools browser extension to inspect Zustand state changes in development.
Testing Stores
__tests__/language-store.test.ts
import { renderHook , act } from "@testing-library/react" ;
import { useLanguageStore } from "@/store/language-store" ;
describe ( "useLanguageStore" , () => {
beforeEach (() => {
localStorage . clear ();
});
it ( "should initialize with default language" , () => {
const { result } = renderHook (() => useLanguageStore ());
expect ( result . current . lang ). toBe ( "ptBR" );
});
it ( "should update language and persist to localStorage" , () => {
const { result } = renderHook (() => useLanguageStore ());
act (() => {
result . current . setLang ( "en" );
});
expect ( result . current . lang ). toBe ( "en" );
expect ( localStorage . getItem ( "portfolio-language" )). toBe ( "en" );
});
it ( "should hydrate from localStorage" , () => {
localStorage . setItem ( "portfolio-language" , "en" );
const { result } = renderHook (() => useLanguageStore ());
act (() => {
result . current . hydrate ();
});
expect ( result . current . lang ). toBe ( "en" );
expect ( result . current . hydrated ). toBe ( true );
});
});
Migration from Context API
If you’re converting from React Context to Zustand:
Before (Context API)
After (Zustand)
// ❌ Old way with Context
const LanguageContext = createContext < LanguageContextType | undefined >( undefined );
export function LanguageProvider ({ children } : { children : ReactNode }) {
const [ lang , setLang ] = useState < Lang >( "ptBR" );
return (
< LanguageContext . Provider value = {{ lang , setLang }} >
{ children }
</ LanguageContext . Provider >
);
}
export function useLanguage () {
const context = useContext ( LanguageContext );
if ( ! context ) throw new Error ( "Must be used within LanguageProvider" );
return context ;
}
Benefits of Migration:
✅ No Provider wrapper needed
✅ Better performance (no Context re-render issues)
✅ Less boilerplate
✅ Easier testing
✅ Built-in TypeScript support
Debugging Tips
Log State Changes
export const useLanguageStore = create < LanguageState >(( set ) => ({
lang: "ptBR" ,
setLang : ( lang ) => {
console . log ( "Language changing to:" , lang );
set ({ lang });
}
}));
Use Redux DevTools
Add devtools middleware and inspect state in browser: import { devtools } from "zustand/middleware" ;
export const useLanguageStore = create < LanguageState >()(
devtools (( set ) => ({ ... }))
);
Check localStorage
Open browser console: localStorage . getItem ( "portfolio-language" );
Hydration Check
Add logging to hydration hook: export function useLanguageHydrate () {
const hydrate = useLanguageStore (( s ) => s . hydrate );
useEffect (() => {
console . log ( "Hydrating language store" );
hydrate ();
}, [ hydrate ]);
}
Quick Reference
Store File Location
src/store/language-store.ts
Usage Pattern
// 1. Import store
import { useLanguageStore } from "@/store/language-store" ;
// 2. Hydrate on mount (in root component)
import { useLanguageHydrate } from "@/app/hooks/use-language-hydrate" ;
useLanguageHydrate ();
// 3. Read state
const lang = useLanguageStore (( s ) => s . lang );
// 4. Update state
const setLang = useLanguageStore (( s ) => s . setLang );
setLang ( "en" );
Store Architecture
State : lang, hydrated
Actions : hydrate(), setLang()
Persistence : localStorage
Hydration : Client-side only