Created
October 27, 2025 13:24
-
-
Save valentin-harrang/91854ed29e293c839adf7cd607821654 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| description: React State Management - Best Practices 2025 | |
| globs: | |
| - "**/*.tsx" | |
| - "**/*.ts" | |
| - "**/*.jsx" | |
| - "**/*.js" | |
| alwaysApply: true | |
| --- | |
| # React State Management - Best Practices 2025 | |
| ## Core Principles | |
| - **Local by default, global on demand** | |
| - Always start with the simplest solution (useState) | |
| - Only escalate to a more complex solution when a real need arises | |
| - Avoid over-engineering and premature abstractions | |
| ## Decision Tree: Which Solution to Use? | |
| ### 1. Local UI / Ephemeral | |
| **Use:** `useState` or `useReducer` | |
| **When:** | |
| - State that only concerns a single component | |
| - Temporary data (modal open/closed, active tab, unsaved input) | |
| - No need to share with other components | |
| **Golden Rule:** Local by default. Never create a Context "just in case". | |
| **Example:** | |
| ```tsx | |
| function Modal() { | |
| const [isOpen, setIsOpen] = useState(false); | |
| // ✅ Correct: local state for ephemeral UI | |
| } | |
| ``` | |
| ### 2. Shared between few components (2-3 levels) | |
| **Use:** Props passing or `React.Context` | |
| **When:** | |
| - State shared between parent and few close children | |
| - Multi-step form | |
| - Shared payload across few components | |
| **Rule:** OK for a small scope, but watch out for frequent re-renders. Prefer props drilling if < 3 levels. Keep Context surface limited to avoid unnecessary re-renders. | |
| **Example:** | |
| ```tsx | |
| // ✅ Props drilling acceptable (2 levels) | |
| <Form> | |
| <Step1 data={formData} onChange={setFormData} /> | |
| </Form> | |
| // ✅ Context for 3+ levels or multiple consumers | |
| const FormContext = createContext(); | |
| ``` | |
| ### 3. Global client-only (non-persisted) | |
| **Use:** `Jotai` or `Zustand` | |
| **When:** | |
| - Global state shared across the entire app | |
| - UI theme, temporary cart state | |
| - Session display preferences | |
| **Rule:** Prefer atomic atoms/stores rather than a centralized mega-store. | |
| **Example:** | |
| ```tsx | |
| // ✅ Jotai - atomic | |
| import { atom, useAtom } from 'jotai'; | |
| const cartAtom = atom([]); | |
| // ✅ Zustand - slice focused | |
| const useCartStore = create((set) => ({ | |
| items: [], | |
| addItem: (item) => set((state) => ({ items: [...state.items, item] })) | |
| })); | |
| ``` | |
| ### 4. Global persisted (multi-sessions) | |
| **Use:** `atomWithStorage` (Jotai) or `Zustand persist` | |
| **When:** | |
| - Dark mode, user language | |
| - Persisted sort/filter preferences | |
| - User settings stored in localStorage | |
| **Rule:** Possible with SSR, but properly handle client/server rehydration. Don't hydrate these values server-side without precautions. | |
| **Example:** | |
| ```tsx | |
| // ✅ Jotai with persistence | |
| import { atomWithStorage } from 'jotai/utils'; | |
| const darkModeAtom = atomWithStorage('darkMode', false); | |
| // ✅ Zustand persist with SSR | |
| const useSettingsStore = create( | |
| persist( | |
| (set) => ({ theme: 'light' }), | |
| { | |
| name: 'user-settings', | |
| // For SSR: skipHydration or conditional getStorage | |
| } | |
| ) | |
| ); | |
| ``` | |
| ### 5. URL-shareable state (deep linking) | |
| **Use:** `nuqs` or `use-query-state` | |
| **When:** | |
| - Search filters | |
| - Pagination | |
| - Product configuration | |
| - Anything that should be bookmarkable/shareable | |
| **Rule:** Practical for sharing, but avoid URLs that are too long (> 2000 chars) or unreadable. Avoid Base64 blobs. | |
| **Example:** | |
| ```tsx | |
| // ✅ nuqs for URL state | |
| import { useQueryState } from 'nuqs'; | |
| function ProductList() { | |
| const [search, setSearch] = useQueryState('q'); | |
| const [page, setPage] = useQueryState('page', { defaultValue: 1 }); | |
| // URL: /products?q=laptop&page=2 | |
| } | |
| ``` | |
| ### 6. Server data (with cache & invalidation) | |
| **Use:** `TanStack Query` (React Query), `Apollo Client`, or `SWR` | |
| **When:** | |
| - API data fetching | |
| - Cache with automatic invalidation | |
| - Product lists, user profile, feature flags | |
| **Rule:** Single source of truth. NEVER duplicate server data in client state (useState, Zustand, etc.). Complementary to local state, not a replacement. | |
| **Example:** | |
| ```tsx | |
| // ✅ TanStack Query as single source | |
| function UserProfile({ userId }) { | |
| const { data: user } = useQuery({ | |
| queryKey: ['user', userId], | |
| queryFn: () => fetchUser(userId) | |
| }); | |
| } | |
| // ❌ AVOID: unnecessary duplication | |
| function UserProfile({ userId }) { | |
| const { data } = useQuery(['user', userId], fetchUser); | |
| const [user, setUser] = useState(data); // ❌ unnecessary duplicate | |
| } | |
| ``` | |
| ### 7. Forms | |
| **Use:** `React Hook Form` | |
| **When:** | |
| - Login, checkout, newsletter | |
| - Forms with validation | |
| - Complex multi-field forms | |
| **Rule:** Ultra performant, limits re-renders thanks to uncontrolled inputs. | |
| **Example:** | |
| ```tsx | |
| // ✅ React Hook Form | |
| import { useForm } from 'react-hook-form'; | |
| function LoginForm() { | |
| const { register, handleSubmit } = useForm(); | |
| return ( | |
| <form onSubmit={handleSubmit(onSubmit)}> | |
| <input {...register('email')} /> | |
| </form> | |
| ); | |
| } | |
| ``` | |
| ## Anti-patterns to Avoid | |
| ❌ **Creating a Context "just in case"** → useState is enough 99% of the time | |
| ❌ **Centralized mega-store** → Prefer atomic stores/atoms | |
| ❌ **Duplicating server data** → TanStack Query is the single source | |
| ❌ **useState for server data** → Use TanStack Query/SWR | |
| ❌ **Context for local UI** → useState only | |
| ❌ **Manual localStorage** → Use atomWithStorage or persist middleware | |
| ❌ **Manual state in URL** → Use nuqs | |
| ❌ **Ignoring SSR issues with persistence** → Handle rehydration | |
| ## Decision Checklist | |
| Before choosing a solution, ask these questions: | |
| 1. **Is this state local to a component?** → useState | |
| 2. **Shared with few close children?** → Props or Context (watch re-renders) | |
| 3. **Global to the app but temporary?** → Jotai/Zustand | |
| 4. **Must persist between sessions?** → atomWithStorage/persist (+ handle SSR) | |
| 5. **Must be in URL?** → nuqs (avoid URLs that are too long) | |
| 6. **Comes from server?** → TanStack Query/SWR (single source) | |
| 7. **Is it a form?** → React Hook Form | |
| ## Visual Summary | |
| ``` | |
| useState → Local UI (modal, toggle) | |
| Props/Context → Few components (multi-step form, watch re-renders) | |
| Jotai/Zustand → Global client (cart, theme) | |
| + Storage → + Persisted (dark mode, preferences, handle SSR) | |
| nuqs → URL state (filters, pagination, avoid long URLs) | |
| TanStack Query → Server data (API, cache, single source) | |
| React Hook Form → Forms (login, checkout, perfs++) | |
| ``` | |
| ## Order of Increasing Complexity | |
| 1. `useState` ← Start here | |
| 2. Props drilling | |
| 3. `React.Context` | |
| 4. `Jotai` / `Zustand` | |
| 5. `TanStack Query` + state management | |
| 6. Advanced combinations | |
| **Final Rule:** Stay at the lowest level possible as long as it suffices. Only extend when the need arises. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment