react-state-management

Par wshobson · agents

Maîtrisez la gestion d'état React moderne avec Redux Toolkit, Zustand, Jotai et React Query. À utiliser lors de la mise en place d'un état global, de la gestion de l'état serveur, ou pour choisir entre différentes solutions de gestion d'état.

npx skills add https://github.com/wshobson/agents --skill react-state-management

Gestion d'état React

Guide complet des patterns modernes de gestion d'état React, du state local des composants aux stores globaux et à la synchronisation d'état serveur.

Quand utiliser cette compétence

  • Mettre en place la gestion d'état global dans une app React
  • Choisir entre Redux Toolkit, Zustand ou Jotai
  • Gérer l'état serveur avec React Query ou SWR
  • Implémenter les mises à jour optimistes
  • Déboguer les problèmes liés à l'état
  • Migrer de Redux legacy vers les patterns modernes

Concepts clés

1. Catégories d'état

Type Description Solutions
Local State État spécifique au composant, UI useState, useReducer
Global State Partagé entre composants Redux Toolkit, Zustand, Jotai
Server State Données distantes, cache React Query, SWR, RTK Query
URL State Paramètres de route, recherche React Router, nuqs
Form State Valeurs d'input, validation React Hook Form, Formik

2. Critères de sélection

Petite app, état simple → Zustand ou Jotai
Grande app, état complexe → Redux Toolkit
Lourde interaction serveur → React Query + état client léger
Mises à jour atomiques/granulaires → Jotai

Démarrage rapide

Zustand (Le plus simple)

// store/useStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  setUser: (user: User | null) => void
  toggleTheme: () => void
}

export const useStore = create<AppState>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        theme: 'light',
        setUser: (user) => set({ user }),
        toggleTheme: () => set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light'
        })),
      }),
      { name: 'app-storage' }
    )
  )
)

// Utilisation dans un composant
function Header() {
  const { user, theme, toggleTheme } = useStore()
  return (
    <header className={theme}>
      {user?.name}
      <button onClick={toggleTheme}>Toggle Theme</button>
    </header>
  )
}

Patterns

Pattern 1 : Redux Toolkit avec TypeScript

// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import userReducer from "./slices/userSlice";
import cartReducer from "./slices/cartSlice";

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ["persist/PERSIST"],
      },
    }),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Hooks typés
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";

interface User {
  id: string;
  email: string;
  name: string;
}

interface UserState {
  current: User | null;
  status: "idle" | "loading" | "succeeded" | "failed";
  error: string | null;
}

const initialState: UserState = {
  current: null,
  status: "idle",
  error: null,
};

export const fetchUser = createAsyncThunk(
  "user/fetchUser",
  async (userId: string, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error("Failed to fetch user");
      return await response.json();
    } catch (error) {
      return rejectWithValue((error as Error).message);
    }
  },
);

const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
    setUser: (state, action: PayloadAction<User>) => {
      state.current = action.payload;
      state.status = "succeeded";
    },
    clearUser: (state) => {
      state.current = null;
      state.status = "idle";
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = "loading";
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.current = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.payload as string;
      });
  },
});

export const { setUser, clearUser } = userSlice.actions;
export default userSlice.reducer;

Pattern 2 : Zustand avec slices (Scalable)

// store/slices/createUserSlice.ts
import { StateCreator } from "zustand";

export interface UserSlice {
  user: User | null;
  isAuthenticated: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

export const createUserSlice: StateCreator<
  UserSlice & CartSlice, // Type de store combiné
  [],
  [],
  UserSlice
> = (set, get) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    const user = await authApi.login(credentials);
    set({ user, isAuthenticated: true });
  },
  logout: () => {
    set({ user: null, isAuthenticated: false });
    // Peut accéder aux autres slices
    // get().clearCart()
  },
});

// store/index.ts
import { create } from "zustand";
import { createUserSlice, UserSlice } from "./slices/createUserSlice";
import { createCartSlice, CartSlice } from "./slices/createCartSlice";

type StoreState = UserSlice & CartSlice;

export const useStore = create<StoreState>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

// Souscriptions sélectives (prévient les re-rendus inutiles)
export const useUser = () => useStore((state) => state.user);
export const useCart = () => useStore((state) => state.cart);

Pattern 3 : Jotai pour l'état atomique

// atoms/userAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

// Atom basique
export const userAtom = atom<User | null>(null)

// Atom dérivé (calculé)
export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null)

// Atom avec persistance localStorage
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')

// Atom asynchrone
export const userProfileAtom = atom(async (get) => {
  const user = get(userAtom)
  if (!user) return null
  const response = await fetch(`/api/users/${user.id}/profile`)
  return response.json()
})

// Atom write-only (action)
export const logoutAtom = atom(null, (get, set) => {
  set(userAtom, null)
  set(cartAtom, [])
  localStorage.removeItem('token')
})

// Utilisation
function Profile() {
  const [user] = useAtom(userAtom)
  const [, logout] = useAtom(logoutAtom)
  const [profile] = useAtom(userProfileAtom) // Suspense-enabled

  return (
    <Suspense fallback={<Skeleton />}>
      <ProfileContent profile={profile} onLogout={logout} />
    </Suspense>
  )
}

Pattern 4 : React Query pour l'état serveur

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

// Fabrique de clés de query
export const userKeys = {
  all: ["users"] as const,
  lists: () => [...userKeys.all, "list"] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, "detail"] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

// Hook de fetch
export function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 30 * 60 * 1000, // 30 minutes (anciennement cacheTime)
  });
}

// Hook utilisateur unique
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    enabled: !!id, // Ne pas fetch si pas d'id
  });
}

// Mutation avec mise à jour optimiste
export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUser,
    onMutate: async (newUser) => {
      // Annuler les refetches en cours
      await queryClient.cancelQueries({
        queryKey: userKeys.detail(newUser.id),
      });

      // Snapshot de la valeur précédente
      const previousUser = queryClient.getQueryData(
        userKeys.detail(newUser.id),
      );

      // Mise à jour optimiste
      queryClient.setQueryData(userKeys.detail(newUser.id), newUser);

      return { previousUser };
    },
    onError: (err, newUser, context) => {
      // Rollback en cas d'erreur
      queryClient.setQueryData(
        userKeys.detail(newUser.id),
        context?.previousUser,
      );
    },
    onSettled: (data, error, variables) => {
      // Refetch après mutation
      queryClient.invalidateQueries({
        queryKey: userKeys.detail(variables.id),
      });
    },
  });
}

Pattern 5 : Combiner l'état client + serveur

// Zustand pour l'état client
const useUIStore = create<UIState>((set) => ({
  sidebarOpen: true,
  modal: null,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  openModal: (modal) => set({ modal }),
  closeModal: () => set({ modal: null }),
}))

// React Query pour l'état serveur
function Dashboard() {
  const { sidebarOpen, toggleSidebar } = useUIStore()
  const { data: users, isLoading } = useUsers({ active: true })
  const { data: stats } = useStats()

  if (isLoading) return <DashboardSkeleton />

  return (
    <div className={sidebarOpen ? 'with-sidebar' : ''}>
      <Sidebar open={sidebarOpen} onToggle={toggleSidebar} />
      <main>
        <StatsCards stats={stats} />
        <UserTable users={users} />
      </main>
    </div>
  )
}

Bonnes pratiques

À faire

  • Colocaliser l'état - Garder l'état aussi proche que possible de son utilisation
  • Utiliser des sélecteurs - Prévenir les re-rendus inutiles avec des souscriptions sélectives
  • Normaliser les données - Aplatir les structures imbriquées pour faciliter les mises à jour
  • Typer tout - La couverture TypeScript complète prévient les erreurs runtime
  • Séparer les préoccupations - État serveur (React Query) vs état client (Zustand)

À ne pas faire

  • Ne pas globalize excessivement - Pas tout doit être en état global
  • Ne pas dupliquer l'état serveur - Laisser React Query le gérer
  • Ne pas muter directement - Toujours utiliser les mises à jour immuables
  • Ne pas stocker les données dérivées - Les calculer à la place
  • Ne pas mélanger les paradigmes - Choisir une solution primaire par catégorie

Guides de migration

De Redux legacy à RTK

// Avant (Redux legacy)
const ADD_TODO = "ADD_TODO";
const addTodo = (text) => ({ type: ADD_TODO, payload: text });
function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, { text: action.payload, completed: false }];
    default:
      return state;
  }
}

// Après (Redux Toolkit)
const todosSlice = createSlice({
  name: "todos",
  initialState: [],
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      // Immer permet les "mutations"
      state.push({ text: action.payload, completed: false });
    },
  },
});

Skills similaires