Accueil / Articles / État dans la pratique : petites boutiques, sélectionneurs précis, suivi des plantes

État dans la pratique : petites boutiques, sélectionneurs précis, suivi des plantes

Remplacez les méthodes prop-drilling et Redux ceremony par create(), des sélecteurs, des actions asynchrones, le middleware persist/devtools, des slices, ainsi qu’une application de type hydration plant.

5694 mots

Une introduction adaptée aux débutants à Zustand — la bibliothèque de gestion d’état pour React avec des dizaines de millions de téléchargements par semaine — ainsi qu’un exemple complet de gestion de plantes.

Dès que quelqu’un vous dit enfin qu’il existe une meilleure façon

Auparavant Zustand : ce que fait réellement React

Les pages web commencent par des balises HTML — div, button, h1. Modifier manuellement ce markup pour un produit complexe est fastidieux : chaque changement de données oblige à rechercher les éléments et à les réécrire. Changement de page d’accueil ? Corriger une douzaine d’endroits. Panier qui s’agrandit ? Corriger encore une douzaine.

React inverse ce modèle. Les composants sont des fonctions qui décrivent l’interface utilisateur à partir des données actuelles ; la bibliothèque décide de ce qui doit être modifié dans le DOM. Les développeurs décrivent ; React met à jour.

function WelcomeBanner() {
  return <h1>Hello, stranger</h1>
}

Cette fonction renvoie du JSX — une syntaxe inspirée de l’HTML que React compile, et non de l’HTML littéral.

Glossaire rapide pour les sections suivantes :

  • JavaScript — le langage à l’origine de console.log et de const x = 5.
  • npm — l’outil d’installation de paquets. npm install zustand télécharge Zustand dans le projet.
  • Hooks — des fonctions dont le nom commence par use (useState, useEffect, hooks de stockage personnalisés). Ce sont l’interface moderne pour gérer les données et les effets dans React.

Si ces trois concepts sont bien compris, le reste de ce guide sera plus facile à assimiler.

React est le chef cuisinier. Vous lui donnez la recette et les ingrédients.

Que signifie vraiment « state »

L’état correspond à tout ce que l’interface doit mémoriser : être connecté ou non, la taille du panier, le fait que la barre latérale soit ouverte, l’URL de l’avatara, le texte dans une boîte de recherche. Les variables simples ne mettent pas automatiquement à jour l’écran. Les hooks React conservent les valeurs et planifient un nouveau rendu lorsque ces valeurs changent.

useState :

import { useState } from 'react'
function Counter() {
  const [count, setCount] = useState(0)
  // Creates a state variable called count, starting at 0.
  // setCount is the only way to change it.
  // Every time setCount runs, React re-renders this component.
  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  )
}

L’état local est utile lorsque une seule composante détient la valeur. Les problèmes surviennent lorsque des composantes éloignées doivent partager la même information — formulaire de connexion, avatara en en-tête, salutation sur le tableau de bord, fonction de déconnexion dans les paramètres — sans être situées côte à côte dans l’arborescence.

Des appels distincts à useState ne permettent pas de communiquer. Le véritable problème réside dans le partage d’informations entre des éléments de l’interface éloignés.

Des états séparés, incapables de communiquer entre eux. Le problème en un seul tableau.

Les difficultés liées au transfert de propriétés

La première solution proposée par React est de « monter l’état » : placer les données partagées dans l’ancêtre commun le plus proche et les transmettre en tant que props (arguments de fonction).

Cela fonctionne pour des arbres courts. Cependant, cela devient problématique lorsque user doit parcourir App → Layout → MainContent → Dashboard → Header → ProfilePic. Les couches intermédiaires n’utilisent jamais ces données ; elles se contentent de les transmettre. Le thème, le panier d’achats et les jetons d’authentification ajoutent chacun une étape supplémentaire via des parents indifférents. Les refacturations endommagent alors des fichiers non liés.

Context a été conçu pour mettre fin à cette chaîne de transmission. Un Provider entoure l’arbre ; les enfants appellent useContext. Le problème : les consommateurs se rérendent souvent à nouveau lorsque quelque partie de la valeur du contexte change, ce qui est problématique pour un état mis à jour fréquemment.

Redux (2015) a proposé un état externe prévisible ainsi que des sélecteurs qui ne réaffichent que ce qui a changé — tout en introduisant de nombreuses formalités dont les équipes se sont lassées à maintenir.

L’arrivée de Zustand

Zustand provient du collectif Poimandres de Paul Henschel (également à l’origine de React Three Fiber et Jotai). Le nom signifie « état » en allemand. Il s’agit d’une mascotte en forme d’ours. Ce projet compte des dizaines de milliers d’étoiles sur GitHub et environ vingt millions de téléchargements hebdomadaires via npm — un chiffre supérieur à celui de Redux classique et de Redux Toolkit réunis au cours de nombreuses semaines récentes.

Toute l’API principale se résume à une seule fonction, create. Il suffit de passer une définition de l’état ainsi que des actions ; on reçoit alors un hook React. Pas de Provider. Pas de constantes pour les types d’actions. Pas de fichiers réducteurs distincts. Exemple :

import { create } from 'zustand'
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

Tout composant peut appeler ce hook. Aucune course de relais nécessaire.

Chaque composant communique directement avec le store. Aucune course de relais requise.

Modèle mental : le store est un message de chat en groupe fixé que tout le monde peut lire et modifier. Redux ressemble davantage à un tribunal — on dépose une demande (action), on attend le jugement du réducteur, puis on consulte les informations via une fenêtre contrôlée. Redux a acheté la prévisibilité au prix de la bureaucratie ; Zustand compte sur des mises à jour disciplinées avec moins de paperasse.

Premier store réel

Créez une application Vite React et installez la bibliothèque :

npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev

Créez useCounterStore.js :

// useCounterStore.js
import { create } from 'zustand'
// We're borrowing one function from the zustand package.
// That's all we need. Zustand doesn't hide other stuff from us, there genuinely isn't more.

const useCounterStore = create((set) => ({
  // create takes a function as its only argument.
  // That function receives two tools named set and get.
  // We only need set for now. It's how we change state.

  count: 0,
  // This is a state field. It lives in the store.
  // Any component in our app can read it.

increment: () =>
    set((state) => ({ count: state.count + 1 })),
  // This is an action. Actions are just functions that call set.
  // We pass set a function that takes the OLD state and returns
  // an object describing what should change.
  // Zustand merges this object into the store for us.
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
  // Another action. Same pattern. Decrement by one.
  reset: () => set({ count: 0 }),
  // When we don't need the old state, we can just pass a plain object.
  // Zustand handles both forms.
}))
export default useCounterStore
// Ship the hook out so other files can import it.

Utilisez-le :

// Counter.jsx
import useCounterStore from './useCounterStore'

function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)
  const reset = useCounterStore((state) => state.reset)
  // Each line is a subscription.
  // We grab exactly what we need and no more.
  // This matters for performance, we'll get to why.
  return (
    <div>
      <h1>Count {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>reset</button>
    </div>
  )
}
export default Counter

Montez <Counter /> n’importe où. Les comptes changent. Remarquez ce qui est absent : des enveloppes Provider, un initialisation du contexte, des constantes d’action, des réducteurs, connect, mapDispatchToProps, une configuration thunk. Une seule appel à create et un hook.

« Attendez, c’est vraiment tout ? »

Dans les coulisses

create crée un objet simple pour stocker l’état ainsi qu’une liste vide de listeners dans le scope du module (un singleton dès que le module est chargé).

Lorsqu’un composant utilise ce hook :

  1. Zustand exécute le sélecteur (par exemple (state) => state.count) sur l’état actuel et renvoie la valeur correspondante.
  2. Il enregistre le composant ainsi que le sélecteur comme listener. Lors des mises à jour ultérieures, il réexécute chaque sélecteur et ne redessine que lorsque la valeur sélectionnée a changé.

Aucun React Context dans le flux principal — c’est plutôt un petit émetteur d’événements complété par des éléments React. Comme le store se trouve en dehors de l’arbre React, chaque import du même module partage une seule instance. Des stores multi-instance classiques existent pour les cas rares où ils sont nécessaires ; la plupart des applications n’ont jamais besoin de cette solution de secours.

Tout le cycle de vie : quatre étapes et une boucle de feedback

Sélecteurs (ne les lisez pas à la hâte)

Les sélecteurs déterminent quand un composant est réaffiché.

Modèle A — toute la base de données (généralement incorrect) :

const store = useCounterStore()
// No selector. Zustand returns the entire store object.
// Your component now re-renders on ANY state change, even unrelated ones.
// If someone else changes a different piece of state you don't care about,
// this component still wastes a render cycle.

Modèle B — un seul champ (généralement correct) :

const count = useCounterStore((state) => state.count)
// Now your component only re-renders when count changes specifically.
// Other state can update freely. This component sleeps through it.

Modèle C — piège des objets littéraux :

const { count, increment } = useCounterStore((state) => ({
  count: state.count,
  increment: state.increment,
}))
// Looks clean. It's a trap.
// This selector returns a NEW object every time it runs.
// Zustand compares the new object to the old object. They're different objects.
// Result: this component re-renders on every state change in the entire store.
// Worse than pattern A for obvious reasons.

Un nouvel objet à chaque appel donne l’impression d’être « nouveau » même lorsque les champs restent inchangés, ce qui provoque des réaffichages constants.

Sélecteur mauvais à gauche. Sélecteur bon à droite. La différence est flagrante dans les applications volumineuses.

Avez-vous besoin de plusieurs champs sans création d’objets inutiles ? Utilisez useShallow :

import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
  useShallow((state) => ({
    count: state.count,
    increment: state.increment,
  }))
)
// useShallow tells Zustand "compare the returned object field by field."
// If count and increment didn't change individually, no re-render.
// Now you get clean destructuring AND performance.

De nombreux projets en production préfèrent plutôt plusieurs appels de hooks ciblés. Règle générale : sélectionnez la partie la plus petite ; en cas de doute, divisez les hooks plutôt que de les fusionner.

Actions : synchrones et asynchrones

Les actions se trouvent sur le même objet que l’état. Les mises à jour synchrones utilisent set :

const useCartStore = create((set, get) => ({
  items: [],

addItem: (product) =>
    set((state) => ({
      items: [...state.items, product],
    })),
  // Spread the existing items into a new array.
  // Add the new product at the end.
  // Return the updated items array to merge back into state.
  removeItem: (productId) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== productId),
    })),
  // Filter out the item with the matching id.
  // Return the filtered array.
  clear: () => set({ items: [] }),
  // Reset to empty. No need to read old state.
}))

get permet de lire l’état actuel à l’intérieur d’une action sans enregistrer d’écouteur React — pratique pour prendre des décisions :

const useWalletStore = create((set, get) => ({
  balance: 100,

tryWithdraw: (amount) => {
    const currentBalance = get().balance
    // Read the current balance at this exact moment.
    // No subscription. No re-render. Just a fresh read.
    if (currentBalance < amount) {
      return { success: false, message: 'Not enough funds' }
      // Bail out without touching state.
    }
    set((state) => ({ balance: state.balance - amount }))
    return { success: true, message: 'Withdrawn successfully' }
  },
}))

Le travail asynchrone s’effectue avec des async/await classiques — sans aucune procédure particulière liée aux middleware thunk :

const usePostStore = create((set) => ({
  posts: [],
  loading: false,
  error: null,

fetchPosts: async () => {
    set({ loading: true, error: null })
    // Flip loading to true. Clear any previous errors.
    // Components showing a spinner will now show it.
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts')
      const data = await response.json()
      // Hit the API. Wait for it. Parse the JSON.
      set({ posts: data, loading: false })
      // Store the posts. Flip loading back to false.
      // Components showing the list now have data.
    } catch (err) {
      set({ error: err.message, loading: false })
      // If anything exploded, record the error message.
      // Components can now show an error banner.
    }
  },
}))

Cette absence de code générique lié aux pipelines constitue la différence pratique par rapport aux configurations asynchrones classiques de Redux.

Middleware : fonctionnalités superposées

Le middleware entoure le créateur du store. Voici les composants courants.

Persist — survivre aux rechargements

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

const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light',
      toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
    }),
    {
      name: 'theme-storage',
      // The key under which Zustand saves your state in localStorage.
      // Name it whatever you want, just make it unique.
    }
  )
)

Le thème (ou les champs que vous choisissez) est restitué après un rechargement. Les Outils de développement → Application → Stockage local affichent le blob JSON correspondant.

Outils de développement — examiner les actions

Malgré la marque Redux, l’extension navigateur fonctionne lorsque le store est entouré de devtools :

import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (s) => ({ count: s.count + 1 }),
          false,
          'counter/increment'
        ),
      // The third argument is the action name shown in DevTools.
      // If you skip it, you'll see "anonymous" for every action, which is useless for debugging.
    }),
    { name: 'CounterStore' }
  )
)

Immer — mises à jour imbriquées sans spread imbriqué

Déploiements imbriqués immuables et problématiques :

// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city,
      },
    },
  },
}))

Permettre aux mises à jour de sembler mutables tout en restant immuables au fond :

import { immer } from 'zustand/middleware/immer'
const useStore = create(
  immer((set) => ({
    user: { profile: { address: { city: '' } } },
    updateCity: (city) =>
      set((state) => {
        state.user.profile.address.city = city
        // Looks like mutation. Isn't actually mutation.
        // Immer tracks the change and produces a new immutable state object.
      }),
  }))
)

Écouteurs externes conscients du sélecteur

Pour les écouteurs non React qui ne doivent s’exécuter que lorsque l’élément sélectionné change, Zustand propose le middleware subscribeWithSelector :

import { subscribeWithSelector } from 'zustand/middleware'
const useCartStore = create(
  subscribeWithSelector((set) => ({
    items: [],
    addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  }))
)
// Somewhere outside React, like in an analytics file:
useCartStore.subscribe(
  (state) => state.items,
  // The selector. We only care about items.
  (items, previousItems) => {
    console.log('Cart changed', { from: previousItems, to: items })
    // This runs every time items changes, with both old and new values.
    // No React component involved. No re-render. Just a side effect.
  }
)

Empilement

const useStore = create(
  persist(
    devtools(
      subscribeWithSelector(
        immer((set, get) => ({
          // your store definition
        }))
      ),
      { name: 'MyStore' }
    ),
    { name: 'my-store-storage' }
  )
)

Mauvais au début, puis oublié.

L’oignon des middleware : chaque couche ajoute une fonctionnalité.

Slices évolutifs

Un seul fichier suffit tant que le panier, l’authentification, le thème et les notifications ne se chevauchent pas. Les fabricants de slices maintiennent les domaines séparés tout en composant un seul stock :

// cartSlice.js
export const createCartSlice = (set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
})

// authSlice.js
export const createAuthSlice = (set, get) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
})
// useAppStore.js
import { create } from 'zustand'
import { createCartSlice } from './cartSlice'
import { createAuthSlice } from './authSlice'
const useAppStore = create((set, get) => ({
  ...createCartSlice(set, get),
  ...createAuthSlice(set, get),
}))

Les composants sélectionnent toujours ce dont ils ont besoin. Au-delà d’environ cinq domaines, les slices deviennent coûteux ; avant cela, un seul fichier est plus clair.

Test sans montage de l’UI

Les stocks ne sont que des modules ordinaires. Réinitialiser, appeler des actions, vérifier :

// useCounterStore.test.js
import useCounterStore from './useCounterStore'

describe('counter store', () => {
  beforeEach(() => {
    useCounterStore.setState({ count: 0 })
    // Reset state before each test.
    // setState is exposed on the hook itself, not just for components.
  })
  it('increments count', () => {
    useCounterStore.getState().increment()
    // getState gives you the current store object outside of React.
    // .increment() calls the action.
    expect(useCounterStore.getState().count).toBe(1)
    // Verify the count went from 0 to 1.
  })
  it('resets count', () => {
    useCounterStore.getState().increment()
    useCounterStore.getState().increment()
    useCounterStore.getState().reset()
    expect(useCounterStore.getState().count).toBe(0)
  })
})

Aucun rendeur, aucun fournisseur fictif — les tests unitaires restent rapides et fiables.

Projet : Station d’arrosage pour plantes

Créez un suivi des soins aux plantes : ajoutez des plantes avec leur fréquence d’arrosage, marquez celles qui ont été arrosées, mettez en évidence celles en retard, affichez des statistiques, et conservez les données après un rechargement. Fonctionnalités :

  1. Ajouter une plante (nom + intervalle entre arrosages)
  2. Lister les plantes
  3. Arroser avec un seul clic
  4. Marquer les plantes assoiffées
  5. Supprimer des plantes
  6. Paneau de statistiques
  7. Conservation des données après un rechargement

Toute l’application sur une seule page. Quatre composants, un stockage, des plantes heureuses.

Fichier 1 — stockage

src/store/usePlantStore.js:

// src/store/usePlantStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

// Helper function. Not exported. Just used internally.
// Given a plant object, returns true if it needs water.
const isThirsty = (plant) => {
  const msPerDay = 1000 * 60 * 60 * 24
  // milliseconds in a second times seconds in a minute
  // times minutes in an hour times hours in a day
  const daysSinceWatering = (Date.now() - plant.lastWatered) / msPerDay
  return daysSinceWatering >= plant.frequencyDays
}
const usePlantStore = create(
  persist(
    (set, get) => ({
      plants: [],
      // The big array that holds every plant.
      // Each plant will be an object with id, name, frequencyDays, lastWatered.
      addPlant: (name, frequencyDays) =>
        set((state) => ({
          plants: [
            ...state.plants,
            {
              id: Date.now() + Math.random(),
              // Quick unique id. Good enough for a personal app.
              // For production use nanoid or uuid from npm.
              name,
              frequencyDays,
              lastWatered: Date.now(),
              // New plants count as freshly watered.
              // Otherwise they'd show as thirsty the second they're added, which is mean.
            },
          ],
        })),
      waterPlant: (id) =>
        set((state) => ({
          plants: state.plants.map((plant) =>
            plant.id === id
              ? { ...plant, lastWatered: Date.now() }
              : plant
          ),
          // Find the matching plant, return a new object with updated timestamp.
          // Leave all other plants untouched.
        })),
      removePlant: (id) =>
        set((state) => ({
          plants: state.plants.filter((plant) => plant.id !== id),
          // Drop the matching plant. Keep everyone else.
        })),
      // These are getter-style helpers using get().
      // They're not stored, they're computed from current state.
      thirstyCount: () => get().plants.filter(isThirsty).length,
      happyCount: () => get().plants.filter((p) => !isThirsty(p)).length,
      isPlantThirsty: (id) => {
        const plant = get().plants.find((p) => p.id === id)
        return plant ? isThirsty(plant) : false
      },
    }),
    {
      name: 'plant-hydration-v1',
      // localStorage key. Prefixing with v1 lets me change schema later
      // without breaking existing users' data.
    }
  )
)
export default usePlantStore

Fichier 2 — formulaire d’ajout

// src/components/AddPlantForm.jsx
import { useState } from 'react'
import usePlantStore from '../store/usePlantStore'

function AddPlantForm() {
  const [name, setName] = useState('')
  const [frequency, setFrequency] = useState(3)
  // These are local to this component.
  // Form inputs are textbook useState territory.
  // They don't need to be global.
  const addPlant = usePlantStore((state) => state.addPlant)
  // Only grab the action we need.
  // We don't care about the plants array here, we don't pull it.
  const handleSubmit = (event) => {
    event.preventDefault()
    // Prevent the default form submission that reloads the page.
    // Modern React always wants this call on form events.
    const trimmed = name.trim()
    if (!trimmed) return
    // Reject empty or whitespace-only names silently.
    addPlant(trimmed, Number(frequency))
    // Call the store action.
    // Number() converts the string from the input into a number.
    setName('')
    setFrequency(3)
    // Clear the form so the user can add another plant easily.
  }
  return (
    <form onSubmit={handleSubmit} className="add-plant-form">
      <h2>Add a Plant</h2>
      <label className="field">
        <span>Plant name</span>
        <input
          type="text"
          value={name}
          onChange={(event) => setName(event.target.value)}
          placeholder="Monstera, Pothos, Something Latin"
        />
      </label>
      <label className="field">
        <span>Water every</span>
        <div className="freq-input">
          <input
            type="number"
            min="1"
            max="60"
            value={frequency}
            onChange={(event) => setFrequency(event.target.value)}
          />
          <span>days</span>
        </div>
      </label>
      <button type="submit">Add Plant</button>
    </form>
  )
}
export default AddPlantForm

Fichier 3 — liste

// src/components/PlantList.jsx
import usePlantStore from '../store/usePlantStore'

function PlantList() {
  const plants = usePlantStore((state) => state.plants)
  const waterPlant = usePlantStore((state) => state.waterPlant)
  const removePlant = usePlantStore((state) => state.removePlant)
  // Three separate subscriptions.
  // Clean. Performant. Obvious.
  if (plants.length === 0) {
    return (
      <div className="empty-state">
        <p>No plants yet. Add one to start tracking.</p>
      </div>
    )
    // Empty state so the UI doesn't look broken.
    // Always tell the user what they can do next.
  }
  const daysSinceWatered = (timestamp) => {
    const msPerDay = 1000 * 60 * 60 * 24
    return Math.floor((Date.now() - timestamp) / msPerDay)
  }
  return (
    <section className="plant-list">
      <h2>Your Plants</h2>
      <ul>
        {plants.map((plant) => {
          const days = daysSinceWatered(plant.lastWatered)
          const thirsty = days >= plant.frequencyDays
          // Compute thirsty status on the fly.
          // Cheap calculation. Premature optimization would be storing this.
          return (
            <li
              key={plant.id}
              className={thirsty ? 'plant thirsty' : 'plant happy'}
            >
              <div className="plant-meta">
                <strong className="plant-name">{plant.name}</strong>
                <span className="plant-when">
                  {days === 0
                    ? 'Watered today'
                    : `Last watered ${days} ${days === 1 ? 'day' : 'days'} ago`}
                </span>
                {thirsty && <span className="badge">THIRSTY</span>}
              </div>
              <div className="plant-actions">
                <button
                  onClick={() => waterPlant(plant.id)}
                  className="btn-primary"
                >
                  Water
                </button>
                <button
                  onClick={() => removePlant(plant.id)}
                  className="btn-danger"
                >
                  Remove
                </button>
              </div>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
export default PlantList

Fichier 4 — statistiques

// src/components/StatsPanel.jsx
import usePlantStore from '../store/usePlantStore'

function StatsPanel() {
  const plants = usePlantStore((state) => state.plants)
  // We subscribe to the plants array because our stats depend on it.
  // When plants change, this re-renders with fresh totals.
  const total = plants.length
  const thirstyCount = plants.filter((plant) => {
    const days = (Date.now() - plant.lastWatered) / (1000 * 60 * 60 * 24)
    return days >= plant.frequencyDays
  }).length
  const happyCount = total - thirstyCount
  return (
    <aside className="stats-panel">
      <h2>Quick Stats</h2>
      <div className="stat-row">
        <span>Total plants</span>
        <strong>{total}</strong>
      </div>
      <div className="stat-row">
        <span>Needs water</span>
        <strong className="danger">{thirstyCount}</strong>
      </div>
      <div className="stat-row">
        <span>Happy plants</span>
        <strong className="success">{happyCount}</strong>
      </div>
      {thirstyCount > 0 && (
        <p className="nudge">
          {thirstyCount === 1
            ? 'One plant is waiting on you.'
            : `${thirstyCount} plants are waiting on you.`}
        </p>
      )}
    </aside>
  )
}
export default StatsPanel

Fichier 5 — coque de l’application

// src/App.jsx
import AddPlantForm from './components/AddPlantForm'
import StatsPanel from './components/StatsPanel'
import PlantList from './components/PlantList'
import './App.css'

function App() {
  return (
    <div className="app">
      <header className="app-header">
        <h1>Plant Hydration Station</h1>
        <p className="tagline">Don't let them down</p>
      </header>
      <div className="grid">
        <AddPlantForm />
        <StatsPanel />
      </div>
      <PlantList />
    </div>
  )
  // Notice something beautiful here.
  // No Provider wrapping anything.
  // No props passed to any component.
  // Every component reaches into the store on its own.
}
export default App

Fichier 6 — CSS

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: system-ui, -apple-system, sans-serif;
  background: #F5F3FF;
  color: #2D3436;
}

.app { max-width: 960px; margin: 0 auto; padding: 24px; }
.app-header {
  background: linear-gradient(135deg, #6C5CE7, #A29BFE);
  color: white;
  padding: 24px 28px;
  border-radius: 16px;
  margin-bottom: 24px;
  box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.app-header h1 { margin: 0; font-size: 28px; }
.tagline { margin: 4px 0 0; opacity: 0.9; font-style: italic; }
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-bottom: 20px;
}
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.add-plant-form, .stats-panel, .plant-list {
  background: white;
  padding: 20px 22px;
  border-radius: 14px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.field { display: block; margin-bottom: 14px; }
.field span { display: block; font-size: 13px; color: #636E72; margin-bottom: 6px; }
.field input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #DFE6E9;
  border-radius: 8px;
  font-size: 14px;
}
.freq-input { display: flex; align-items: center; gap: 10px; }
.freq-input input { width: 80px; }
button {
  border: none;
  padding: 10px 18px;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  font-size: 14px;
}
.add-plant-form button[type="submit"] {
  background: #6C5CE7;
  color: white;
  width: 100%;
}
.btn-primary { background: #0984E3; color: white; }
.btn-danger { background: #FFE5E0; color: #E17055; }
.stat-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #F1F2F6; }
.stat-row:last-of-type { border: none; }
.stat-row .danger { color: #E17055; }
.stat-row .success { color: #00B894; }
.nudge { background: #FFF5F0; color: #E17055; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; }
.plant-list ul { list-style: none; padding: 0; margin: 0; }
.plant { display: flex; justify-content: space-between; align-items: center; padding: 14px 6px; border-bottom: 1px solid #F1F2F6; }
.plant:last-child { border: none; }
.plant.thirsty .plant-name::before { content: "🚨 "; }
.plant-meta { display: flex; flex-direction: column; gap: 3px; }
.plant-name { font-size: 15px; }
.plant-when { font-size: 12px; color: #636E72; }
.badge { background: #FFE5E0; color: #E17055; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; display: inline-block; margin-top: 2px; }
.plant-actions { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 40px 20px; color: #636E72; }

Exécutez npm run dev, ajoutez des plantes, mettez à jour la page, vérifiez qu’elles restent affichées, arrosez-en une et observez les timestamps se réinitialiser. Le stockage local conserve les mêmes données dans une deuxième fenêtre.

Votre application est terminée ; vous pouvez maintenant la personnaliser comme vous le souhaitez.

Vérification avec les outils du navigateur

Application / Stockage → Stockage local → la clé plant-hydration-v1 contient le JSON persisté. Le middleware Persist écrit les modifications et rehydrate les données avant l’affichage. Avec le middleware devtools ainsi que l’extension Redux DevTools, chaque action est affichée avec une fonctionnalité de navigation dans le temps pour les bases de données plus volumineuses.

Erreurs fréquentes

  1. Crochets applicatifs sur l’ensemble du stockage — useStore() sans sélecteur redessine la page à chaque modification. Il faut toujours utiliser un sélecteur.
  2. Objets frais provenant des sélecteurs — corrigez cela en utilisant useShallow ou en séparant les crochets.
  • Stockage des valeurs dérivées — calculez thirstyCount à partir de plants ; ne conservez pas un deuxième compteur obsolète.
  • Omission du champ persistant name — obligatoire ; son absence provoque une erreur au démarrage.
  • Un seul méga-stockage à vie — divisez-le lorsque le nombre de domaines augmente.
  • Cérémonie Redux dans Zustand — évitez les enums de types d’actions et les réducteurs switch géants sauf en cas de véritable nécessité.
  • Oublier les singletons — une fonction create par module est partagée ; des API classiques existent pour les besoins par instance.
  • Sauter les tests du stockage — utilisez getState, act, assert ; détectez tôt les dérives.
  • Lorsque Zustand n’est pas l’outil adapté

    • useState — UI véritablement locale (modaux, effets de survol, brouillons de champs).
  • TanStack Query / SWR — cache serveur, rechargement, suppression des doublons, mises à jour distantes optimistes. Utilisez Query pour l’état serveur et Zustand pour l’état client.
  • Context — mises à jour rares (tokens de thème) diffusées dans tout un sous-arbre.
  • Keep Redux — bases de code Redux existantes, investissements importants dans des outils de gestion du temps de développement, ou flux véritablement complexes où la structure elle-même compense les coûts. Migratez uniquement lorsque les économies dépassent le coût de la migration.
  • Esquisse en TypeScript

    import { create } from 'zustand'
    
    type Plant = {
      id: number
      name: string
      frequencyDays: number
      lastWatered: number
    }
    type PlantState = {
      plants: Plant[]
      addPlant: (name: string, frequencyDays: number) => void
      waterPlant: (id: number) => void
      removePlant: (id: number) => void
      thirstyCount: () => number
    }
    const usePlantStore = create<PlantState>((set, get) => ({
      plants: [],
      addPlant: (name, frequencyDays) =>
        set((state) => ({
          plants: [
            ...state.plants,
            { id: Date.now(), name, frequencyDays, lastWatered: Date.now() },
          ],
        })),
      waterPlant: (id) =>
        set((state) => ({
          plants: state.plants.map((p) =>
            p.id === id ? { ...p, lastWatered: Date.now() } : p
          ),
        })),
      removePlant: (id) =>
        set((state) => ({
          plants: state.plants.filter((p) => p.id !== id),
        })),
      thirstyCount: () =>
        get().plants.filter((p) => {
          const days = (Date.now() - p.lastWatered) / (1000 * 60 * 60 * 24)
          return days >= p.frequencyDays
        }).length,
    }))
    

    Un type définissant la structure du stockage plus create<...> ; l’inférence gère le reste.

    Perspective plus large

    Zustand n’a pas éliminé l’infrastructure déjà en place de Redux — les réécritures sont coûteuses — mais les nouveaux projets React privilégient de plus en plus des stores clients plus légers. Les enquêtes de satisfaction menées lors des dernières éditions du State of React placent Zustand parmi les premiers choix en termes de volonté de l’utiliser à nouveau. Une stack courante pour 2026 : Zustand pour l’état client, TanStack Query pour l’état serveur, parfois des atomes Jotai, Context pour le thème et les paramètres de configuration, Redux Toolkit uniquement si déjà présent, ainsi que useState classique pour l’interface utilisateur locale.

    Cette combinaison permet généralement une intégration plus rapide que les frameworks axés sur Redux et génère moins de contraintes au quotidien pour les développeurs.

    Zustand Bear

    Les équipes de développement continuent de documenter les conventions des stores (nommage, limites des slices, clés de persistance) car la liberté sans normes engendre le chaos. L’avantage, c’est que ces conventions restent simples : les sélecteurs sont précis, les actions coexistent avec l’état, le middleware est utilisé de manière intentionnelle, et la mémoire cache du serveur reste hors du store client. En maintenant ces limites claires, Zustand demeure la solution en dix lignes face au kit de démarrage Redux de cent lignes — sans prétendre que chaque application soit à jamais un exemple de compteur.

    Outre l’échantillon de code, les mêmes principes s’appliquent aux paniers d’achat, aux flags fonctionnels, aux brouillons de wizards et à l’interface utilisateur. Commencez par un seul fichier de stockage, ajoutez la persistance lorsque les utilisateurs détestent perdre leurs travaux, intégrez DevTools lorsque les bugs deviennent subtils, introduisez les slices lorsque la barre de défilement du fichier devient inutile, et conservez Query pour tout ce qui nécessite des appels vers une API. Cette progression correspond à la manière dont la plupart des bases de code Zustand les plus réussies évoluent réellement : petit à petit, de manière explicite, en évitant les formalités inutiles qui ne garantissent pas la sécurité.

    Une exploration plus approfondie des sélecteurs et de la performance

    La discipline dans l’utilisation des sélecteurs fait toute la différence entre un tableau de bord réactif et un tableau de bord étrangement lent. Chaque re-render inutile entraîne une exécution supplémentaire du JSX, des effets dépendant des props et un réalignement des éléments enfants. Le modèle de listeners de Zustand n’est économique que lorsque les sélecteurs renvoient des primitives stables ou des structures comparées avec soin.

    Préférez sélectionner des booléens, des nombres et des chaînes de caractères. Lorsqu’une fonction d’action est choisie, elle reste généralement stable au fil des mises à jour car elle se trouve dans l’objet store, donc il est acceptable d’associer count et increment dans deux hooks. Évitez de sélectionner des tableaux entiers si le composant n’a besoin que de items.length ; choisissez plutôt la longueur (ou un booléen dérivé) afin que les mises à jour ailleurs ne réveillent pas des widgets inactifs.

    L’égalité est importante. La comparaison par défaut est Object.is. C’est pourquoi renvoyer { a, b } depuis un sélecteur échoue : un nouvel objet ne satisfait pas la condition Object.is, même lorsque a et b restent inchangés. useShallow compare un seul niveau de champs. Pour les structures profondes, normalisez l’état afin que l’UI litte des champs plats, ou calculez délibérément une empreinte primitive.

    Les listes méritent une attention particulière. Il est acceptable de diviser les plants en trois composants tant que chaque composant n’a besoin que de la référence du tableau lorsque changent l’appartenance ou l’identité d’un élément. Si un panneau ne montre que des noms, envisagez un sélecteur qui renvoie une chaîne de IDs triés ; cette chaîne reste stable même lorsque des champs liés aux plantes changent.

    Péchés de la persistance et versionning

    Le middleware de persistance semble magique jusqu’à ce qu’un changement de schéma intervienne. Définissez toujours un name stable pour la clé de stockage. Lorsque la structure de l’état persistant change, augmentez la version et fournissez une fonction migrate afin que les anciens fichiers JSON ne provoquent pas de plantage de l’application lors du chargement. La persistance partielle (partialize) permet d’éviter que des informations sensibles ou des paramètres temporaires de l’interface ne soient stockés dans Local Storage — les tokens et les bits d’ouverture de modaux utilisés une seule fois ne devraient généralement pas être enregistrés sur disque.

    Faites attention au moment de l’hydratation : la première affichage du client peut brièvement montrer les valeurs par défaut avant que l’hydratation ne soit terminée. Pour le SSR ou les frameworks qui dessinent sur le serveur, masquez l’interface utilisateur dépendante de valeurs persistées derrière un indicateur d’hydratation, ou acceptez temporairement l’apparition du thème par défaut. Documentez la méthode choisie afin que les collègues ne tentent pas de « corriger » des bugs passagers qui sont en réalité dus à des conflits d’hydratation.

    Le comportement entre onglets surprend également les utilisateurs. Les écritures dans Local Storage depuis un onglet sont visibles dans les autres via l’événement storage, mais le chemin de persistance par défaut de Zustand ne fusionne pas automatiquement les modifications simultanées. Pour des onglets collaboratifs, soit vous acceptez le principe du dernier enregistrement gagne, soit vous ajoutez une couche de synchronisation explicite via BroadcastChannel par-dessus le stockage.

    Concevoir des actions qui restent simples

    Les actions en bon état sont de petite taille, portent des noms reflétant l’intention de l’utilisateur et ne contiennent pas de JSX. addPlant, waterPlant et removePlant sont préférables aux appels setPlants qui encombrent l’interface utilisateur. Gardez la validation près de l’action : rejetez les noms vides, restreignez les intervalles d’arrosage et ignorez les identifiants inconnus. Retourner précocement depuis une action est plus clair que de laisser des données incorrectes dans le store pendant un cycle de rendu.

    Les actions asynchrones doivent définir explicitement les champs loading et error lorsque l’interface doit refléter l’avancement. Un enregistrement sans suivi peut omettre ces indicateurs. Lorsque plusieurs appels asynchrones se chevauchent, capturez un identifiant de requête ou utilisez un contrôleur d’annulation afin qu’une réponse plus ancienne ne puisse pas écraser une réponse plus récente. Rien de tout cela n’exige de middleware — seulement une séquence rigoureuse des appels set.

    Les aides dérivées peuvent exister sous forme de fonctions simples en dehors du store (plus faciles à tester) ou comme des getters calculés à l’intérieur des sélecteurs. Préférez les fonctions pures importées tant par le store que par l’interface utilisateur afin que Jest puisse vérifier les calculs liés à l’arrosage sans React.

    Notes de guidée pour l’application Plant

    Lorsque vous collez les six fichiers, assurez-vous que les chemins d’import correspondent à celui du modèle Vite (../store/... depuis components). Si la liste semble vide après mise à jour, vérifiez la clé de persistance dans les outils de développement et confirmez que name: 'plant-hydration-v1' correspond à ce que vous attendez. L’arrosage doit mettre à jour lastWateredAt (ou équivalent) afin que le sélecteur concerné change sans rechargement complet de la page.

    Le style dans App.css est délibérément simple. Vous pouvez changer les polices et les couleurs librement ; l’objectif d’apprentissage est le flux de données, et non la perfection visuelle. Ajouter un quatrième composant — par exemple un bouton « arroser tous ceux qui ont soif » — constitue un exercice utile : sélectionnez les identifiants des éléments assoiffés, puis appelez une action qui effectue l’arrosage pour tous ces identifiants en une seule opération set. Cet exercice renforce l’idée d’effectuer des mises à jour en lot plutôt que de boucler sur des set depuis le composant.

    Conventions d’équipe à noter

    Convenez de la structure des fichiers (stores/ contre des dossiers de fonctionnalités regroupés), de la nomenclature (useXStore) et du fait que les actions peuvent appeler directement des API ou doivent passer par une mutation Query qui met ensuite à jour Zustand. De nombreuses équipes interdisent complètement d’insérer des listes de serveurs dans Zustand, ne conservant là que des indicateurs temporaires côté client. Écrivez cette règle une fois dans le README ; cela empêche la moitié du codebase de réinventer un deuxième cache.

    Convenez également de la nomenclature des outils DevTools : le middleware devtools accepte un nom de store afin que le panneau d’extension reste lisible lorsque cinq stores sont ouverts. Les clés de persistance doivent être nommées selon l’application (myapp-theme-v1) pour éviter les conflits sur des domaines partagés.

    Comparer le poids émotionnel, et pas seulement le nombre de lignes de code

    Redux Toolkit a considérablement simplifié Redux classique, de sorte que l’opposition « 100 lignes contre 10 » est en partie rhétorique. Le contraste plus profond réside dans la complexité conceptuelle : les slices contre une seule appel à create, les reducers contre des actions en place, les pipelines de middleware contre des enveloppes optionnelles, les arbres Provider contre les singulaires de module. Les ingénieurs qui pensent déjà en termes de reducers peuvent être productifs dans les deux approches. Ceux qui souhaitent un état client partagé sans adhérer à une philosophie basée sur des machines d’état terminent généralement plus rapidement leurs fonctionnalités avec Zustand.

    Rien de tout cela ne justifie de sauter les revues. Un store de dix lignes peut encore contenir une faille de sécurité s’il conserve des données personnelles sans consentement, ou une faille de performance si chaque composant accède à toutes les données. Traitez le store comme une API de module publique : noms d’actions stables, clés de persistance documentées, et tests pour les cas difficiles (listes vides, IDs invalides, requêtes échouées).

    Fermer le cycle d’apprentissage

    Lorsque l’application de gestion des plantes fonctionne, brisez-la délibérément : supprimez un sélecteur, retournez un objet littéral, persistez sans nom, stockez un comptage dérivé. Observez les modes de défaillance une fois afin qu’ils soient reconnaissables lors des revues de code. Restaurez ensuite les schémas disciplinés. Ce bref exercice est plus efficace pour la mémoire à long terme qu’une autre démonstration de compteur parfaitement fonctionnelle.

    Zustand part du principe que la plupart des états côté client sont ennuyeux — des flags, des brouillons, des paniers, du chrome — et que des états ennuyeux méritent une API ennuyeuse. Conservez la vérité serveur dans une bibliothèque de cache conçue à cet effet, gardez l’interface utilisateur locale avec useState, et réservez cette approche aux données clients transversales qui rendaient à l’origine le transfert de propriétés si pénible. En agissant ainsi de manière cohérente, l’affirmation selon laquelle il suffit de « dix lignes » cesse de ressembler à du marketing pour correspondre réellement à la structure du code.