7 min read

Server vs client state management

Server vs client state management

Ever wondered why your order management UI feels snappy for filters but sluggish when loading customer lists? It's all about server state vs client state - two different beasts that need different strategies.

Think of it like this: client state is your React app's short-term memory (filters, modals, selections), while server state is the sacred database truth (orders, customers, inventory) that lives on your backend. Get this wrong, and your users will feel the pain!

Let's dive into an order management app that shows both working together smoothly. 🚀


Client State vs Server State: The Big Picture

Client state lives in your browser - it's fast, disappears on refresh, and controls your UI:

  • Which tab is active? (Orders/Customers/Reports)
  • "Show only pending orders" filter
  • Modal open/closed
  • Which row is selected?

Server state lives on your backend - it's the source of truth:

  • Actual orders from /api/orders
  • Customer details from /api/customers/123
  • Inventory levels, shipping rates

This builds on our previous articles:


How they differ in practice

Think of client state as your UI memory and server state as your source of truth in the database.​

Key differences:​

  • Ownership
    • Client state: owned by React components (useState, useReducer, Context, Redux).
    • Server state: owned by backend/database; React only caches a copy.
  • Persistence
    • Client: disappears on refresh unless you store in localStorage/sessionStorage.
    • Server: survives refresh, shared across users, persisted.
  • Update flow
    • Client: update and re-render immediately.
    • Server: call API → wait for response → sync UI.

In an order management app:

  • Client state: which tab is active (Orders / Customers / Reports), search query, pagination page.
  • Server state: paginated /orders?page=2&status=pending/customers/:id, etc.

Example 1: Pure Client State (Draft Orders)

Let's start simple - a draft order creator that lives only in memory. Perfect for seeing client state in action!

src/OrderDraftForm.jsx

import React, { useState } from "react";

function OrderDraftForm({ onCreateDraft }) {
  const [customerName, setCustomerName] = useState("");
  const [sku, setSku] = useState("");
  const [quantity, setQuantity] = useState(1);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!customerName.trim() || !sku.trim()) {
      alert("Customer name and SKU are required!");
      return;
    }

    onCreateDraft({
      id: Date.now(),
      customerName,
      sku,
      quantity: Number(quantity),
      status: "DRAFT",
    });

    // Reset form
    setCustomerName("");
    setSku("");
    setQuantity(1);
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: "1rem", padding: "1rem", border: "1px solid #ddd" }}>
      <h3>📝 Create Order Draft (Pure Client State)</h3>
      <div style={{ marginBottom: "0.5rem" }}>
        <label>
          Customer: 
          <input
            type="text"
            value={customerName}
            onChange={(e) => setCustomerName(e.target.value)}
            style={{ marginLeft: "0.5rem", padding: "0.25rem" }}
            placeholder="e.g. John Doe"
          />
        </label>
      </div>
      <div style={{ marginBottom: "0.5rem" }}>
        <label>
          SKU: 
          <input
            type="text"
            value={sku}
            onChange={(e) => setSku(e.target.value)}
            style={{ marginLeft: "0.5rem", padding: "0.25rem" }}
            placeholder="e.g. Tshirt-BLUE-M"
          />
        </label>
      </div>
      <div style={{ marginBottom: "0.5rem" }}>
        <label>
          Quantity: 
          <input
            type="number"
            min="1"
            value={quantity}
            onChange={(e) => setQuantity(e.target.value)}
            style={{ marginLeft: "0.5rem", padding: "0.25rem", width: "60px" }}
          />
        </label>
      </div>
      <button type="submit" style={{ padding: "0.5rem 1rem", background: "#007bff", color: "white", border: "none", borderRadius: "4px" }}>
        ➕ Add Draft
      </button>
    </form>
  );
}

export default OrderDraftForm;

src/OrderDraftList.jsx

import React from "react";

function OrderDraftList({ drafts, onDeleteDraft }) {
  if (drafts.length === 0) {
    return <p style={{ color: "#666" }}>No drafts yet. Create one above! ✨</p>;
  }

  return (
    <div style={{ padding: "1rem", border: "1px solid #ddd", borderRadius: "8px" }}>
      <h3>📋 Draft Orders (Browser Memory Only)</h3>
      <ul style={{ listStyle: "none", padding: 0 }}>
        {drafts.map((draft) => (
          <li key={draft.id} style={{ 
            padding: "0.75rem", 
            marginBottom: "0.5rem", 
            background: "#f8f9fa", 
            borderRadius: "4px",
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center"
          }}>
            <span>
              <strong>{draft.customerName}</strong> wants {draft.sku} × {draft.quantity} 
              <span style={{ color: "#28a745", fontWeight: "bold", marginLeft: "0.5rem" }}>
                [{draft.status}]
              </span>
            </span>
            <button
              onClick={() => onDeleteDraft(draft.id)}
              style={{
                padding: "0.25rem 0.75rem",
                background: "#dc3545",
                color: "white",
                border: "none",
                borderRadius: "4px",
                cursor: "pointer"
              }}
            >
              🗑️ Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default OrderDraftList;

src/App.js (Client-only version)

jsximport React, { useState } from "react";
import OrderDraftForm from "./OrderDraftForm";
import OrderDraftList from "./OrderDraftList";

function App() {
  const [drafts, setDrafts] = useState([]);
  const [activeDraftId, setActiveDraftId] = useState(null);

  const handleCreateDraft = (draft) => {
    setDrafts((prev) => [...prev, draft]);
    setActiveDraftId(draft.id);
  };

  const handleDeleteDraft = (id) => {
    setDrafts((prev) => prev.filter((draft) => draft.id !== id));
    if (activeDraftId === id) {
      setActiveDraftId(null);
    }
  };

  const activeDraft = drafts.find((d) => d.id === activeDraftId);

  return (
    <div style={{ 
      maxWidth: "800px", 
      margin: "0 auto", 
      padding: "2rem", 
      fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif"
    }}>
      <header style={{ textAlign: "center", marginBottom: "2rem" }}>
        <h1 style={{ color: "#333", marginBottom: "0.5rem" }}>
          🛒 Order Management Demo
        </h1>
        <p style={{ color: "#666", fontSize: "1.1rem" }}>
          Client State Only - Try creating/deleting drafts!
        </p>
      </header>

      <OrderDraftForm onCreateDraft={handleCreateDraft} />
      <OrderDraftList drafts={drafts} onDeleteDraft={handleDeleteDraft} />
      
      {activeDraft && (
        <div style={{ 
          marginTop: "1rem", 
          padding: "1rem", 
          background: "#e7f3ff", 
          borderRadius: "8px",
          borderLeft: "4px solid #007bff"
        }}>
          <h3>🎯 Active Draft</h3>
          <p>
            <strong>{activeDraft.customerName}</strong> - {activeDraft.sku} × {activeDraft.quantity}
          </p>
        </div>
      )}
      
      <div style={{ marginTop: "2rem", padding: "1rem", background: "#fff3cd", borderRadius: "8px", borderLeft: "4px solid #ffc107" }}>
        <p><strong>💡 Notice:</strong> Refresh the page - all drafts disappear! This is pure <em>client state</em>.</p>
      </div>
    </div>
  );
}

export default App;

This example is essentially a local/global client state problem only; no server calls yet. This reuses ideas from earlier articles on local vs global state and lifting state up (activeDraftId), but everything lives in memory.


Example 2: Server State Enters the Chat! 🔥

Now let's add confirmed orders that come from our backend (fake API). Drafts stay client-side, confirmed orders become server state.

src/fakeOrdersApi.js

// Fake backend simulation for server state
let ORDERS_DB = [
  { id: 1, customerName: "Alice Johnson", sku: "LAPTOP-001", quantity: 2, status: "CONFIRMED" },
  { id: 2, customerName: "Bob Smith", sku: "PHONE-XYZ", quantity: 1, status: "SHIPPED" },
  { id: 3, customerName: "Carol Davis", sku: "HEADPHONES-PRO", quantity: 3, status: "CONFIRMED" }
];

export function fetchOrders() {
  return new Promise((resolve) => {
    setTimeout(() => resolve([...ORDERS_DB]), 800); // Simulate network delay
  });
}

export function createOrder(orderData) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const newOrder = {
        ...orderData,
        id: Date.now(),
        status: "CONFIRMED"
      };
      ORDERS_DB.push(newOrder);
      resolve(newOrder);
    }, 1000);
  });
}

export function updateOrderStatus(orderId, status) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const orderIndex = ORDERS_DB.findIndex(o => o.id === orderId);
      if (orderIndex === -1) {
        reject(new Error("Order not found"));
        return;
      }
      ORDERS_DB[orderIndex] = { ...ORDERS_DB[orderIndex], status };
      resolve(ORDERS_DB[orderIndex]);
    }, 800);
  });
}

src/OrderListServer.jsx

import React, { useEffect, useState } from "react";
import { fetchOrders } from "./fakeOrdersApi";

function OrderListServer({ onOrderSelect }) {
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [statusFilter, setStatusFilter] = useState("ALL"); // Client state!

  useEffect(() => {
    let mounted = true;
    setLoading(true);
    
    fetchOrders()
      .then(data => {
        if (mounted) setOrders(data);
      })
      .catch(() => {
        if (mounted) setError("Failed to fetch orders 😞");
      })
      .finally(() => {
        if (mounted) setLoading(false);
      });

    return () => { mounted = false; };
  }, []);

  const filteredOrders = statusFilter === "ALL" 
    ? orders 
    : orders.filter(order => order.status === statusFilter);

  if (loading) {
    return (
      <div style={{ padding: "2rem", textAlign: "center" }}>
        <div style={{ fontSize: "2rem", marginBottom: "1rem" }}>⏳</div>
        <p>Loading orders from server...</p>
      </div>
    );
  }

  if (error) {
    return <div style={{ color: "#dc3545", padding: "1rem" }}>❌ {error}</div>;
  }

  return (
    <div style={{ marginTop: "2rem" }}>
      <div style={{ display: "flex", gap: "1rem", marginBottom: "1rem", alignItems: "center" }}>
        <h3>✅ Confirmed Orders (Server State)</h3>
        <div>
          <label>Filter: </label>
          <select
            value={statusFilter}
            onChange={(e) => setStatusFilter(e.target.value)}
            style={{ padding: "0.25rem", borderRadius: "4px" }}
          >
            <option value="ALL">All Orders</option>
            <option value="CONFIRMED">Pending</option>
            <option value="SHIPPED">Shipped</option>
          </select>
        </div>
      </div>
      
      {filteredOrders.length === 0 ? (
        <p>No orders match your filter. Try "All Orders"! 🔍</p>
      ) : (
        <div style={{ border: "1px solid #ddd", borderRadius: "8px", overflow: "hidden" }}>
          {filteredOrders.map((order) => (
            <div
              key={order.id}
              style={{
                padding: "1rem",
                borderBottom: "1px solid #eee",
                cursor: "pointer",
                transition: "background 0.2s",
                "&:hover": { background: "#f8f9fa" }
              }}
              onClick={() => onOrderSelect(order)}
            >
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                <div>
                  <strong>{order.customerName}</strong>
                  <span style={{ margin: "0 1rem" }}>| {order.sku} × {order.quantity}</span>
                  <span style={{
                    padding: "0.25rem 0.75rem",
                    background: order.status === "SHIPPED" ? "#d4edda" : "#cce5ff",
                    color: order.status === "SHIPPED" ? "#155724" : "#004085",
                    borderRadius: "20px",
                    fontSize: "0.85rem",
                    fontWeight: "bold"
                  }}>
                    {order.status}
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default OrderListServer;

This example combines:

  • Client local state (form, active draft, loading flags).​
  • Client global-ish UI state (activeOrderId shared between lists).​
  • Server state for confirmed orders, fetched and cached on the client.​

When to use client vs server state

A simple way to think about the choice:​

  • Use client state for:
    • UI-only data: modal visibility, active tabs, filters, sort order.
    • Ephemeral data: unsaved form values, temporary selections.
    • Derived data: search results computed from an already fetched list.
  • Use server state for:
    • Data shared across users: orders, inventory, customers.
    • Data that must be correct even if user refreshes or logs in on another device.
    • Data validated/secured by backend (permissions, pricing, limits).

In your order management context:

  • Drafts before “Save” → typically client state (or server state in a dedicated drafts table, depending on product requirements).
  • Confirmed orders, invoices → server state.

This is also where your local vs global state decision framework, Context, and Redux Toolkit articles plug in: they all talk about how to organize client-side state once you have fetched server data.


The Client vs Server Decision Framework

Here's your cheat sheet for order management apps:

Use CLIENT STATE when...Use SERVER STATE when...
Modal is open/closedOrders, customers, inventory
Filter/sort preferencesData shared across users
Form drafts (before save)Data that must be accurate
Selected row/expanded rowsBackend validation needed

Pro tip: Filters on client state = instant UI. Orders on server state = data safety! 🎯


Bonus: Context for Shared Client Filters

Want to share that status filter across components? Use Context API! (Covered in previous article)


Next up: "React Router v6 — nested routes and guards" 🚀

Ready to try? Copy these files into a fresh Create React App. Watch client drafts vanish on refresh, but confirmed orders persist! Magic! ✨