React State Management - Manage State Like a Pro

When your app grows, managing state with just useState quickly becomes chaos. We're going to see how to structure and share state efficiently.
That's where stores (or global state management systems) come in.


The State Management Problem

What is State?

State is all the data that can change in your application:

  • User data (profile, preferences)
  • Interface state (open modals, loading)
  • Business data (products, orders)
  • API cache

The Problems That Arise

With useState alone, you'll quickly encounter these problems:

// ❌ Problem: Prop Drilling
function App() {
  const [user, setUser] = useState(null)
  const [cart, setCart] = useState([])
  const [theme, setTheme] = useState('light')

  return (
    <Header user={user} theme={theme} setTheme={setTheme} />
    <ProductList cart={cart} setCart={setCart} user={user} />
    <Footer theme={theme} />
  )
}

function Header({ user, theme, setTheme }) {
  return (
    <header>
      <UserProfile user={user} />
      <ThemeToggle theme={theme} setTheme={setTheme} />
    </header>
  )
}

// You pass the props down 5 levels... 😱

The Solutions We'll See

  1. Context API → to share state without prop drilling
  2. Zustand → simple and modern state manager
  3. Redux Toolkit → for large, complex apps
  4. Jotai → atomic approach
  5. TanStack Query → for server data

Context API - The Native React Solution

Basic Concept

The Context API allows you to share data throughout the app without passing it as props.

// 1. Create a Context
const UserContext = createContext()

// 2. Provide the data (Provider)
function App() {
  const [user, setUser] = useState(null)

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Header />
      <MainContent />
    </UserContext.Provider>
  )
}

// 3. Consume the data
function Header() {
  const { user } = useContext(UserContext)

  return <h1>Hello {user?.name}!</h1>
}

Complete Example: Theme Provider

import { createContext, useContext, useState } from 'react'

// 1. Create the Context
const ThemeContext = createContext()

// 2. Custom Hook to use the context
export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// 3. Provider Component
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light')
  }

  const value = {
    theme,
    setTheme,
    toggleTheme,
    isDark: theme === 'dark'
  }

  return (
    <ThemeContext.Provider value={value}>
      <div className={`app theme-${theme}`}>
        {children}
      </div>
    </ThemeContext.Provider>
  )
}

// 4. Usage in components
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme()

  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙' : '☀️'}
      Switch to {theme === 'light' ? 'dark' : 'light'} mode
    </button>
  )
}

function Header() {
  const { isDark } = useTheme()

  return (
    <header className={isDark ? 'header-dark' : 'header-light'}>
      <h1>My App</h1>
      <ThemeToggle />
    </header>
  )
}

// 5. Setup in App.jsx
function App() {
  return (
    <ThemeProvider>
      <Header />
      <MainContent />
    </ThemeProvider>
  )
}

Multiple Contexts with Composition

// Auth Context
const AuthContext = createContext()

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(false)

  const login = async (email, password) => {
    setIsLoading(true)
    try {
      // API Call
      const userData = await api.login(email, password)
      setUser(userData)
    } catch (error) {
      throw error
    } finally {
      setIsLoading(false)
    }
  }

  const logout = () => {
    setUser(null)
    localStorage.removeItem('token')
  }

  return (
    <AuthContext.Provider value={{
      user,
      login,
      logout,
      isLoading,
      isAuthenticated: !!user
    }}>
      {children}
    </AuthContext.Provider>
  )
}

// Cart Context
const CartContext = createContext()

export function CartProvider({ children }) {
  const [items, setItems] = useState([])

  const addItem = (product) => {
    setItems(prev => {
      const existing = prev.find(item => item.id === product.id)
      if (existing) {
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      }
      return [...prev, { ...product, quantity: 1 }]
    })
  }

  const removeItem = (productId) => {
    setItems(prev => prev.filter(item => item.id !== productId))
  }

  const updateQuantity = (productId, quantity) => {
    if (quantity <= 0) {
      removeItem(productId)
      return
    }

    setItems(prev =>
      prev.map(item =>
        item.id === productId
          ? { ...item, quantity }
          : item
      )
    )
  }

  const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0)

  return (
    <CartContext.Provider value={{
      items,
      addItem,
      removeItem,
      updateQuantity,
      total,
      itemCount: items.reduce((sum, item) => sum + item.quantity, 0)
    }}>
      {children}
    </CartContext.Provider>
  )
}

// Composition of providers
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <Router>
            <Layout />
          </Router>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  )
}

Custom Hooks for Contexts

// hooks/useAuth.js
export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

// hooks/useCart.js
export function useCart() {
  const context = useContext(CartContext)
  if (!context) {
    throw new Error('useCart must be used within CartProvider')
  }
  return context
}

// Simplified usage
function ProductCard({ product }) {
  const { addItem } = useCart()
  const { isAuthenticated } = useAuth()

  const handleAddToCart = () => {
    if (!isAuthenticated) {
      alert('Log in to add to cart')
      return
    }
    addItem(product)
  }

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price}€</p>
      <button onClick={handleAddToCart}>
        Add to cart
      </button>
    </div>
  )
}
```## Zustand - Modern and Simple State Manager

### Why Zustand?

**Zustand** (= "state" in German) is an **ultra simple** and **performant** state manager:

- 📦 **Tiny**: 2.5kb gzipped
- 🚀 **Fast**: no providers needed
- 🔧 **Simple**: minimalist API
- 🎯 Native **TypeScript**

### Installation

```bash
npm install zustand

Basic Store

import { create } from 'zustand'

// Create a store
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}))

// Use the store (anywhere in the app)
function Counter() {
  const { count, increment, decrement, reset } = useCounterStore()

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

// In another component (no provider needed!)
function CounterDisplay() {
  const count = useCounterStore((state) => state.count)

  return <p>Current value: {count}</p>
}

Complex Store: E-commerce

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

// Interface for TypeScript (optional)
interface Product {
  id: string
  name: string
  price: number
  image: string
}

interface CartItem extends Product {
  quantity: number
}

interface CartStore {
  items: CartItem[]
  isOpen: boolean
  addItem: (product: Product) => void
  removeItem: (productId: string) => void
  updateQuantity: (productId: string, quantity: number) => void
  clearCart: () => void
  toggleCart: () => void
  total: number
  itemCount: number
}

// Store with middleware
const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        isOpen: false,

        addItem: (product) => set((state) => {
          const existingItem = state.items.find(item => item.id === product.id)

          if (existingItem) {
            return {
              items: state.items.map(item =>
                item.id === product.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              )
            }
          } else {
            return {
              items: [...state.items, { ...product, quantity: 1 }]
            }
          }
        }),

        removeItem: (productId) => set((state) => ({
          items: state.items.filter(item => item.id !== productId)
        })),

        updateQuantity: (productId, quantity) => set((state) => {
          if (quantity <= 0) {
            return {
              items: state.items.filter(item => item.id !== productId)
            }
          }

          return {
            items: state.items.map(item =>
              item.id === productId
                ? { ...item, quantity }
                : item
            )
          }
        }),

        clearCart: () => set({ items: [] }),

        toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),

        // Computed values
        get total() {
          return get().items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
        },

        get itemCount() {
          return get().items.reduce((sum, item) => sum + item.quantity, 0)
        }
      }),
      {
        name: 'cart-storage', // localStorage key
        partialize: (state) => ({ items: state.items }) // Only persist items
      }
    ),
    { name: 'cart-store' } // Name for Redux DevTools
  )
)

// Usage in components
function ProductCard({ product }) {
  const addItem = useCartStore((state) => state.addItem)

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}€</p>
      <button onClick={() => addItem(product)}>
        Add to cart
      </button>
    </div>
  )
}

function CartSummary() {
  const { itemCount, total, toggleCart } = useCartStore()

  return (
    <button onClick={toggleCart} className="cart-button">
      🛒 {itemCount} items - {total.toFixed(2)}€
    </button>
  )
}

function CartModal() {
  const { items, isOpen, removeItem, updateQuantity, clearCart, toggleCart } = useCartStore()

  if (!isOpen) return null

  return (
    <div className="cart-modal">
      <div className="cart-content">
        <div className="cart-header">
          <h2>Your cart</h2>
          <button onClick={toggleCart}></button>
        </div>

        {items.length === 0 ? (
          <p>Your cart is empty</p>
        ) : (
          <>
            {items.map(item => (
              <div key={item.id} className="cart-item">
                <img src={item.image} alt={item.name} />
                <div>
                  <h4>{item.name}</h4>
                  <p>{item.price}€</p>
                </div>
                <div className="quantity-controls">
                  <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
                    -
                  </button>
                  <span>{item.quantity}</span>
                  <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
                    +
                  </button>
                </div>
                <button onClick={() => removeItem(item.id)}>
                  🗑️
                </button>
              </div>
            ))}
            <div className="cart-actions">
              <button onClick={clearCart} className="btn-secondary">
                Clear cart
              </button>
              <button className="btn-primary">
                Order ({total.toFixed(2)}€)
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  )
}

Store with async actions

const useProductStore = create((set, get) => ({
  products: [],
  isLoading: false,
  error: null,

  // Synchronous action
  setProducts: (products) => set({ products }),

  // Asynchronous action
  fetchProducts: async () => {
    set({ isLoading: true, error: null })

    try {
      const response = await fetch('/api/products')
      if (!response.ok) throw new Error('Error loading')

      const products = await response.json()
      set({ products, isLoading: false })
    } catch (error) {
      set({ error: error.message, isLoading: false })
    }
  },

  // Filter products
  searchProducts: (query) => {
    const { products } = get()
    return products.filter(product =>
      product.name.toLowerCase().includes(query.toLowerCase())
    )
  },

  // Find a product
  getProductById: (id) => {
    const { products } = get()
    return products.find(product => product.id === id)
  }
}))

// Custom hook for filtered products
function useProductSearch(query = '') {
  return useProductStore((state) =>
    query ? state.searchProducts(query) : state.products
  )
}

// Usage
function ProductList() {
  const [searchQuery, setSearchQuery] = useState('')
  const { isLoading, error, fetchProducts } = useProductStore()
  const products = useProductSearch(searchQuery)

  useEffect(() => {
    fetchProducts()
  }, [])

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <div>
      <input
        type="text"
        placeholder="Search for a product..."
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
      />

      <div className="products-grid">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  )
}

Slices (split the store)

// For large stores, we can split into slices

// authSlice.js
const createAuthSlice = (set, get) => ({
  user: null,
  token: null,
  isAuthenticated: false,

  login: async (email, password) => {
    try {
      const response = await api.login(email, password)
      set({
        user: response.user,
        token: response.token,
        isAuthenticated: true
      })
    } catch (error) {
      throw error
    }
  },

  logout: () => set({
    user: null,
    token: null,
    isAuthenticated: false
  })
})

// cartSlice.js
const createCartSlice = (set, get) => ({
  items: [],
  total: 0,

  addToCart: (product) => {
    // Logic...
  }
})

// Main store
const useAppStore = create((set, get) => ({
  ...createAuthSlice(set, get),
  ...createCartSlice(set, get)
}))

Redux Toolkit - For large applications

When to use Redux?

Redux is useful for:

  • Large teams (10+ developers)
  • Very complex apps (100+ components)
  • Complex business logic (state machines)
  • Advanced debugging (time travel, replay)

Installation

npm install @reduxjs/toolkit react-redux

Basic Store with RTK

// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: (state) => {
      state.value += 1  // Immer allows direct mutation
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    },
    reset: (state) => {
      state.value = 0
    }
  }
})

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
export default counterSlice.reducer

// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer
  }
})

// main.jsx
import { Provider } from 'react-redux'
import { store } from './store'

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

// Usage in a component
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, reset } from './store/counterSlice'

function Counter() {
  const count = useSelector((state) => state.counter.value)
  const dispatch = useDispatch()

  return (
    <div>
      <h2>Counter: {count}</h2>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(reset())}>Reset</button>
    </div>
  )
}

RTK Query for API calls

// api/productsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const productsApi = createApi({
  reducerPath: 'productsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/',
    prepareHeaders: (headers, { getState }) => {
      const token = getState().auth.token
      if (token) {
        headers.set('authorization', `Bearer ${token}`)
      }
      return headers
    }
  }),
  tagTypes: ['Product'],
  endpoints: (builder) => ({
    getProducts: builder.query({
      query: () => 'products',
      providesTags: ['Product']
    }),
    getProductById: builder.query({
      query: (id) => `products/${id}`,
      providesTags: (result, error, id) => [{ type: 'Product', id }]
    }),
    createProduct: builder.mutation({
      query: (newProduct) => ({
        url: 'products',
        method: 'POST',
        body: newProduct
      }),
      invalidatesTags: ['Product']
    }),
    updateProduct: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `products/${id}`,
        method: 'PATCH',
        body: patch
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Product', id }]
    }),
    deleteProduct: builder.mutation({
      query: (id) => ({
        url: `products/${id}`,
        method: 'DELETE'
      }),
      invalidatesTags: ['Product']
    })
  })
})

export const {
  useGetProductsQuery,
  useGetProductByIdQuery,
  useCreateProductMutation,
  useUpdateProductMutation,
  useDeleteProductMutation
} = productsApi

// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import { productsApi } from '../api/productsApi'

export const store = configureStore({
  reducer: {
    [productsApi.reducerPath]: productsApi.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(productsApi.middleware)
})

// Component
function ProductList() {
  const { data: products, error, isLoading } = useGetProductsQuery()
  const [deleteProduct] = useDeleteProductMutation()

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div className="products-grid">
      {products?.map(product => (
        <div key={product.id} className="product-card">
          <h3>{product.name}</h3>
          <p>{product.price}€</p>
          <button onClick={() => deleteProduct(product.id)}>
            Delete
          </button>
        </div>
      ))}
    </div>
  )
}

Jotai - Atomic Approach

Concept of Atoms

Jotai uses atoms: small, independent units of state.

npm install jotai
import { atom, useAtom } from 'jotai'

// Create atoms
const countAtom = atom(0)
const nameAtom = atom('Andy')

function Counter() {
  const [count, setCount] = useAtom(countAtom)

  return (
    <div>
      <p>Counter: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  )
}

function Greeting() {
  const [name, setName] = useAtom(nameAtom)

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Your name"
      />
      <p>Hello {name}!</p>
    </div>
  )
}

Derived Atoms

import { atom, useAtom, useAtomValue } from 'jotai'

// Base atoms
const firstNameAtom = atom('Andy')
const lastNameAtom = atom('Cin')

// Derived (computed) atom
const fullNameAtom = atom((get) => {
  const firstName = get(firstNameAtom)
  const lastName = get(lastNameAtom)
  return `${firstName} ${lastName}`
})

// Derived atom with setter
const upperCaseNameAtom = atom(
  (get) => get(fullNameAtom).toUpperCase(),
  (get, set, newValue) => {
    const [first, last] = newValue.split(' ')
    set(firstNameAtom, first)
    set(lastNameAtom, last)
  }
)

function NameForm() {
  const [firstName, setFirstName] = useAtom(firstNameAtom)
  const [lastName, setLastName] = useAtom(lastNameAtom)
  const fullName = useAtomValue(fullNameAtom)
  const [upperName, setUpperName] = useAtom(upperCaseNameAtom)

  return (
    <div>
      <input
        value={firstName}
        onChange={(e) => setFirstName(e.target.value)}
        placeholder="First name"
      />
      <input
        value={lastName}
        onChange={(e) => setLastName(e.target.value)}
        placeholder="Last name"
      />
      <p>Full name: {fullName}</p>
      <p>In uppercase: {upperName}</p>
      <button onClick={() => setUpperName('JOHN DOE')}>
        Change to John Doe
      </button>
    </div>
  )
}

Atoms with storage

import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

// Persist in localStorage
const themeAtom = atomWithStorage('theme', 'light')

// Atom for user preferences
const userPrefsAtom = atomWithStorage('userPrefs', {
  theme: 'light',
  language: 'fr',
  notifications: true
})

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom)

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Mode {theme === 'light' ? 'dark' : 'light'}
    </button>
  )
}

TanStack Query

Key Concept

TanStack Query (formerly React Query) is perfect for managing:

  • Cache of API data
  • Server/client synchronization
  • Automatic Loading states
  • Optimistic updates
npm install @tanstack/react-query

Basic Setup

// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router>
        <Layout />
      </Router>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Queries (reading data)

import { useQuery, useQueryClient } from '@tanstack/react-query'

// Custom hook for products
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const response = await fetch('/api/products')
      if (!response.ok) throw new Error('Error loading')
      return response.json()
    }
  })
}

// Hook for a specific product
function useProduct(productId) {
  return useQuery({
    queryKey: ['products', productId],
    queryFn: async () => {
      const response = await fetch(`/api/products/${productId}`)
      if (!response.ok) throw new Error('Product not found')
      return response.json()
    },
    enabled: !!productId // Do not execute if no ID
  })
}

// Usage in components
function ProductList() {
  const { data: products, isLoading, error } = useProducts()

  if (isLoading) return <div>Loading products...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div className="products-grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

function ProductDetail({ productId }) {
  const { data: product, isLoading, error } = useProduct(productId)

  if (isLoading) return <div>Loading product...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>{product.price}€</p>
    </div>
  )
}

Mutations (modifying data)

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useCreateProduct() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (newProduct) => {
      const response = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newProduct)
      })
      if (!response.ok) throw new Error('Error creating')
      return response.json()
    },
    onSuccess: () => {
      // Invalidate the products cache
      queryClient.invalidateQueries({ queryKey: ['products'] })
      alert('Product created successfully!')
    },
    onError: (error) => {
      alert(`Error: ${error.message}`)
    }
  })
}

function useUpdateProduct() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ id, ...updates }) => {
      const response = await fetch(`/api/products/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      if (!response.ok) throw new Error('Error updating')
      return response.json()
    },
    onSuccess: (updatedProduct) => {
      // Update the cache directly
      queryClient.setQueryData(['products', updatedProduct.id], updatedProduct)
      queryClient.invalidateQueries({ queryKey: ['products'] })
    }
  })
}

// Usage
function CreateProductForm() {
  const createProduct = useCreateProduct()
  const [formData, setFormData] = useState({ name: '', price: 0 })

  const handleSubmit = (e) => {
    e.preventDefault()
    createProduct.mutate(formData)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Product name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <input
        type="number"
        placeholder="Price"
        value={formData.price}
        onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
      />
      <button
        type="submit"
        disabled={createProduct.isPending}
      >
        {createProduct.isPending ? 'Creating...' : 'Create product'}
      </button>
    </form>
  )
}

Optimistic Updates

function useToggleFavorite() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ productId, isFavorite }) => {
      const response = await fetch(`/api/products/${productId}/favorite`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ isFavorite })
      })
      if (!response.ok) throw new Error('Erreur')
      return response.json()
    },

    // Optimistic update
    onMutate: async ({ productId, isFavorite }) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['products', productId] })

      // Snapshot the previous value
      const previousProduct = queryClient.getQueryData(['products', productId])

      // Optimistically update to the new value
      queryClient.setQueryData(['products', productId], old => ({
        ...old,
        isFavorite
      }))

      // Return a context object with the snapshotted value
      return { previousProduct, productId }
    },

    // If the mutation fails, use the context returned from onMutate to roll back
    onError: (err, variables, context) => {
      queryClient.setQueryData(
        ['products', context.productId],
        context.previousProduct
      )
    },

    // Always refetch after error or success:
    onSettled: (data, error, variables) => {
      queryClient.invalidateQueries({ queryKey: ['products', variables.productId] })
    }
  })
}

function FavoriteButton({ product }) {
  const toggleFavorite = useToggleFavorite()

  const handleToggle = () => {
    toggleFavorite.mutate({
      productId: product.id,
      isFavorite: !product.isFavorite
    })
  }

  return (
    <button onClick={handleToggle}>
      {product.isFavorite ? '❤️' : '🤍'} Favorite
    </button>
  )
}
```## Resources for further learning

### Official Documentation

- 📚 [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction)
- 🚀 [TanStack Query](https://tanstack.com/query/latest)
- 🔧 [Redux Toolkit](https://redux-toolkit.js.org/)
- ⚛️ [Jotai](https://jotai.org/)

### Comparisons and Guides

- 📊 [State Management Comparison](https://react-state-management.com/)
- 🎯 [When to use what](https://kentcdodds.com/blog/application-state-management-with-react)
- 🔍 [Performance comparisons](https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode)