Goodbye, useState()

 

David Khourshid · @davidkpiano

stately.ai

useState()

useState()

useState()

useState()

useState()

useState()

useEffect()
useDefect()
useFoot(() => {
  // 🦶🔫
  setGun(true); 
});

We need to talk about

useState

Closures, batching, weird stuff

useState

const [count, setCount] = useState(0);

return (
  <button onClick={() => {
    setCount(count + 60);

    setCount(count + 8);

    setCount(count + 1);
  }}>
    Count: {count}
  </button>
);

1

const [count, setCount] = useState(0);

return (
  <button onClick={() => {
    setCount(c => c + 1);

    setCount(c => c * 2);

    setCount(c => c + 40);
  }}>
    Count: {count}
  </button>
);

42

const [set, setSet] = useState(new Set());

return (
  <button onClick={() => {
    setSet(set.add('set'));
  }}>
    Set set
  </button>
);
const [[set], setSet] = useState([new Set()]);

return (
  <button onClick={() => {
    setSet([set.add('set')]);
  }}>
    Set set
  </button>
);

🤷

Closures, batching, weird stuff

useState

  • Use state setters
  • Beware of closures
  • Keep object identity in mind

Controlling rerenders

useState

function Component() {
  const [value, setValue] = useState('bonjour');

  return (
    <Foo>
      <h1>{value}</h1>
      <Bar />
      <Baz />
      <Quo>
        <Inner someProp={value} onChange={setValue} />
      </Quo>
    </Foo>
  );
}

Entire component tree rerenders

function CounterWithUseState() {
  const [clickCount, setClickCount] = useState(0);
  
  const handleClick = () => {
    // ⚠️ Rerender!
    setClickCount(clickCount + 1);
    analytics
      .trackEvent('button_clicked', { count: clickCount + 1 });
  };
  
  return (
    <button onClick={handleClick}>
      Click Me
    </button>
  );
}
function CounterWithUseRef() {
  const clickCount = useRef(0);
  
  const handleClick = () => {
    // This updates the ref without causing a re-render
    clickCount.current += 1;
    analytics
      .trackEvent('button_clicked', { count: clickCount.current });
  };
  
  return (
    <button onClick={handleClick}>
      Click Me
    </button>
  );
}

Controlling rerenders

useState

  • Keep useState as local as possible
  • useRef for internal state that shouldn't rerender

URL as state

useState

import { useState } from 'react';

function SortComponent() {
  const [sort, setSort] = useState<string | null>(null);

  return (
    <>
      <p>Current sort: {sort}</p>

      <button onClick={() => setSort('asc')}>ASC</button>
      <button onClick={() => setSort('desc')}>DESC</button>
    </>
  );
}
import { usePathname, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/router';
import { useCallback } from 'react';

function ExampleClientComponent() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const sort = searchParams.get('sort');

  const createQueryString = useCallback(
    (name: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      params.set(name, value);

      return params.toString();
    },
    [searchParams]
  );

  return (
    <>
      <p>Current sort: {sort}</p>

      <button
        onClick={() => {
          router.push(pathname + '?' + createQueryString('sort', 'asc'));
        }}
      >
        ASC
      </button>
      <button
        onClick={() => {
          router.push(pathname + '?' + createQueryString('sort', 'desc'));
        }}
      >
        DESC
      </button>
    </>
  );
}
import { useSearchParams } from '@remix-run/react';

function SortComponent() {
  const [searchParams, setSearchParams] = useSearchParams();
  const sort = searchParams.get('sort');

  return (
    <>
      <p>Current sort: {sort}</p>

      <button onClick={() => setSearchParams({ sort: 'asc' })}>ASC</button>
      <button onClick={() => setSearchParams({ sort: 'desc' })}>DESC</button>
    </>
  );
}
"use client";

import { parseAsInteger, useQueryState } from "nuqs";

export function Demo() {
  const [hello, setHello] = useQueryState("hello", { defaultValue: "" });
  const [count, setCount] = useQueryState(
    "count",
    parseAsInteger.withDefault(0),
  );
  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
      <input
        value={hello}
        placeholder="Enter your name"
        onChange={(e) => setHello(e.target.value || null)}
      />
      <p>Hello, {hello || "anonymous visitor"}!</p>
    </>
  );
}

URL as state

useState

Use search params for URL-persisted state

Watch the next talk on nuqs

Fetching data

useState

'use client';

function ProductDetailsClient({ productId }) {
  const [product, setProduct] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  const [activeTab, setActiveTab] = useState('description');
  
  useEffect(() => {
    setIsLoading(true);
    fetchProduct(productId)
      .then(data => {
        setProduct(data);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setIsLoading(false);
      });
  }, [productId]);
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!product) return null;
  
  return (
    <div>
      <h1>{product.name}</h1>
      <div className="tabs">
        <button 
          className={activeTab === 'description' ? 'active' : ''} 
          onClick={() => setActiveTab('description')}
        >
          Description
        </button>
        <button 
          className={activeTab === 'specs' ? 'active' : ''} 
          onClick={() => setActiveTab('specs')}
        >
          Specifications
        </button>
        <button 
          className={activeTab === 'reviews' ? 'active' : ''} 
          onClick={() => setActiveTab('reviews')}
        >
          Reviews
        </button>
      </div>
      
      {activeTab === 'description' && <div>{product.description}</div>}
      {activeTab === 'specs' && <SpecsTable specs={product.specifications} />}
      {activeTab === 'reviews' && <ReviewsList reviews={product.reviews} />}
      
      <AddToCartForm product={product} />
    </div>
  );
}
async function ProductDetailsServer({ productId, searchParams }) {
  const product = await fetchProduct(productId);
  const activeTab = searchParams.tab ?? 'description';
  
  return (
    <div>
      <h1>{product.name}</h1>
      
      {/* Client component just for the tab switching UI */}
      <ClientTabs activeTab={activeTab} />
      
      {/* Server rendered content based on active tab */}
      {activeTab === 'description' && <div>{product.description}</div>}
      {activeTab === 'specs' && <SpecsTable specs={product.specifications} />}
      {activeTab === 'reviews' && <ReviewsList reviews={product.reviews} />}
      
      {/* Client component for the interactive cart functionality */}
      <ClientAddToCartForm productId={product.id} price={product.price} />
    </div>
  );
}

<Suspense>

I'll keep you in

'use client';

import { useTransition } from 'react';
import { makeCafeAllonge } from '../actions';

export function AddFlowButton() {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => {
        startTransition(async () => {
          await makeCafeAllonge();
        });
      }}
      disabled={isPending}
    >
      {isPending ? 'Making...' : 'Make a cafe allongé'}
    </button>
  );
}

It handles fetching data better than you

Fetching data

useState

  • Use server components when applicable
  • Use Suspense for loading states
  • useTransition to handle local pending states*
  • Eliminate boilerplate; use query libraries

Forms

useState

import { z } from 'zod';
import { useState } from 'react';

const User = z.object({
  firstName: z.string(),
  variable: z.string(),
  bio: z.string(),
});

function App() {
  const [firstName, setFirstName] = useState('');
  const [variable, setVariable] = useState('');
  const [bio, setBio] = useState('');

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = { firstName, variable, bio };
    console.log(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        <strong>Name</strong>
        <input
          name="firstName"
          placeholder="First name"
          value={firstName}
          onChange={(e) => setFirstName(e.target.value)}
        />
      </label>
      <label>
        <strong>Favorite variable</strong>
        <select
          name="variable"
          value={variable}
          onChange={(e) => setVariable(e.target.value)}
        >
          <option value="">Select...</option>
          <option value="foo">foo</option>
          <option value="bar">bar</option>
          <option value="baz">baz</option>
          <option value="quo">quo</option>
        </select>
      </label>
      <label>
        <strong>Bio</strong>
        <textarea
          name="bio"
          placeholder="About you"
          value={bio}
          onChange={(e) => setBio(e.target.value)}
        />
      </label>
      <p>Submit and check console</p>
      <input type="submit" />
    </form>
  );
}
import { z } from 'zod';

const User = z.object({
  firstName: z.string(),
  variable: z.string(),
  bio: z.string(),
});

function App() {
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
	const formData = new FormData(event.target);
    const data = User.parse(Object.fromEntries(formData));

    console.log(data);
  };
  
  return (
    <form
      onSubmit={handleSubmit}
    >
      <label>
        <strong>Name</strong>
        <input name="firstName" placeholder="First name" />
      </label>
      <label>
        <strong>Favorite variable</strong>
        <select name="variable">
          <option value="">Select...</option>
          <option value="foo">foo</option>
          <option value="bar">bar</option>
          <option value="baz">baz</option>
          <option value="quo">quo</option>
        </select>
      </label>
      <label>
        <strong>Bio</strong>
        <textarea name="bio" placeholder="About you" />
      </label>
      <p>Submit and check console</p>
      <input type="submit" />
    </form>
  );
}
import { z } from 'zod';
import { Form } from '@remix-run/react';

const User = z.object({
  firstName: z.string(),
  variable: z.string(),
  bio: z.string(),
});

function App() {
  return (
    <Form method="post" action="some-action">
      <label>
        <strong>Name</strong>
        <input name="firstName" placeholder="First name" />
      </label>
      <label>
        <strong>Favorite variable</strong>
        <select name="variable">
          <option value="">Select...</option>
          <option value="foo">foo</option>
          <option value="bar">bar</option>
          <option value="baz">baz</option>
          <option value="quo">quo</option>
        </select>
      </label>
      <label>
        <strong>Bio</strong>
        <textarea name="bio" placeholder="About you" />
      </label>
      <p>Submit and check console</p>
      <input type="submit" />
    </Form>
  );
}
'use client';

import { useActionState } from 'react';
import { createUser } from '@/app/actions';

const initialState = {
  message: '',
};

export function Signup() {
  const [state, formAction, pending] =
    useActionState(createUser, initialState);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p>{state?.message}</p>
      <button disabled={pending}>Sign up</button>
    </form>
  );
}

nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

'use server';

import { redirect } from 'next/navigation';

export async function createUser(prevState: any, formData: FormData) {
  const email = formData.get('email');
  const res = await fetch('https://...');
  const json = await res.json();

  if (!res.ok) {
    return { message: 'Please enter a valid email' };
  }

  redirect('/dashboard');
} 

nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

Forms

useState

  • Use native FormData for simple forms
  • Use runtime type-checking libraries for parsing & validation
  • Use built-in framework features for forms
  • Use form libraries for advanced forms

Overusing state

useState

const [firstName, setFirstName] = useState('');
const [firstName, setFirstName] = useState('');
const [age, setAge] = useState(0);
const [address1, setAddress1] = useState('');
const [address2, setAddress2] = useState('');
const [city, setCity] = useState('');
const [state, setState] = useState('');
const [zip, setZip] = useState('');
const [name, setName] = useState('');
function ReactParisConference() {
  const [startDate, setStartDate] = useState(new Date());
  const [endDate, setEndDate] = useState(new Date());
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [location, setLocation] = useState('');
  const [url, setUrl] = useState('');
  const [image, setImage] = useState('');
  const [price, setPrice] = useState(0);
  const [attendees, setAttendees] = useState(0);
  const [organizer, setOrganizer] = useState('');
  const [countries, setCountries] = useState([]);
  const [categories, setCategories] = useState([]);
  const [tags, setTags] = useState([]);
  const [swag, setSwag] = useState([]);
  const [speakers, setSpeakers] = useState([]);
  const [sponsors, setSponsors] = useState([]);
  const [videos, setVideos] = useState([]);
  const [tickets, setTickets] = useState([]);
  const [schedule, setSchedule] = useState([]);
  const [socials, setSocials] = useState([]);
  const [coffee, setCoffee] = useState([]);
  const [codeOfConduct, setCodeOfConduct] = useState('');

  // ...
}

useState

use eight

VIBE CODING
STATE MANAGEMENT

function UserProfileForm() {
  // Personal information
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
  const [phone, setPhone] = useState('');
  const [birthDate, setBirthDate] = useState('');
  
  // Address information
  const [street, setStreet] = useState('');
  const [city, setCity] = useState('');
  const [state, setState] = useState('');
  const [zipCode, setZipCode] = useState('');
  const [country, setCountry] = useState('US');
  
  // Preferences
  const [theme, setTheme] = useState('light');
  const [emailNotifications, setEmailNotifications] = useState(true);
  const [smsNotifications, setSmsNotifications] = useState(false);
  const [language, setLanguage] = useState('en');
  const [currency, setCurrency] = useState('USD');
  
  // This requires individual handler functions for each field
  const handleFirstNameChange = (e) => {
    setFirstName(e.target.value);
  };
  
  const handleLastNameChange = (e) => {
    setLastName(e.target.value);
  };
  
  const handleEmailChange = (e) => {
    setEmail(e.target.value);
  };
  
  const handlePhoneChange = (e) => {
    setPhone(e.target.value);
  };
  
  const handleBirthDateChange = (e) => {
    setBirthDate(e.target.value);
  };
  
  const handleStreetChange = (e) => {
    setStreet(e.target.value);
  };
  
  const handleCityChange = (e) => {
    setCity(e.target.value);
  };
  
  const handleStateChange = (e) => {
    setState(e.target.value);
  };
  
  const handleZipCodeChange = (e) => {
    setZipCode(e.target.value);
  };
  
  const handleCountryChange = (e) => {
    setCountry(e.target.value);
  };
  
  const handleThemeChange = (e) => {
    setTheme(e.target.value);
  };
  
  const handleEmailNotificationsChange = (e) => {
    setEmailNotifications(e.target.checked);
  };
  
  const handleSmsNotificationsChange = (e) => {
    setSmsNotifications(e.target.checked);
  };
  
  const handleLanguageChange = (e) => {
    setLanguage(e.target.value);
  };
  
  const handleCurrencyChange = (e) => {
    setCurrency(e.target.value);
  };
  
  // When you need to do something with all the data, you have to gather it manually
  const handleSubmit = (e) => {
    e.preventDefault();
    
    const userData = {
      personal: {
        firstName,
        lastName,
        email,
        phone,
        birthDate
      },
      address: {
        street,
        city,
        state,
        zipCode,
        country
      },
      preferences: {
        theme,
        emailNotifications,
        smsNotifications,
        language,
        currency
      }
    };
    
    saveUserProfile(userData);
  };
  
  // Reset all the fields individually
  const handleReset = () => {
    setFirstName('');
    setLastName('');
    setEmail('');
    setPhone('');
    setBirthDate('');
    setStreet('');
    setCity('');
    setState('');
    setZipCode('');
    setCountry('US');
    setTheme('light');
    setEmailNotifications(true);
    setSmsNotifications(false);
    setLanguage('en');
    setCurrency('USD');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>Personal Information</h2>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input
          id="firstName"
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </div>
      <div>
        <label htmlFor="lastName">Last Name</label>
        <input
          id="lastName"
          value={lastName}
          onChange={handleLastNameChange}
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={handleEmailChange}
        />
      </div>
      <div>
        <label htmlFor="phone">Phone</label>
        <input
          id="phone"
          value={phone}
          onChange={handlePhoneChange}
        />
      </div>
      <div>
        <label htmlFor="birthDate">Birth Date</label>
        <input
          id="birthDate"
          type="date"
          value={birthDate}
          onChange={handleBirthDateChange}
        />
      </div>
      
      <h2>Address</h2>
      {/* Address fields with their own handlers */}
      
      <h2>Preferences</h2>
      {/* Preference fields with their own handlers */}
      
      <div>
        <button type="submit">Save Profile</button>
        <button type="button" onClick={handleReset}>Reset</button>
      </div>
    </form>
  );
}
function UserProfileForm() {
  const [userProfile, setUserProfile] = useState({
    personal: {
      firstName: '',
      lastName: '',
      email: '',
      phone: '',
      birthDate: ''
    },
    address: {
      street: '',
      city: '',
      state: '',
      zipCode: '',
      country: 'US'
    },
    preferences: {
      theme: 'light',
      emailNotifications: true,
      smsNotifications: false,
      language: 'en',
      currency: 'USD'
    }
  });
  
  const handleChange = (section, field, value) => {
    setUserProfile(prevProfile => ({
      ...prevProfile,
      [section]: {
        ...prevProfile[section],
        [field]: value
      }
    }));
  };

  const handleReset = () => {
    setUserProfile({
      personal: {
        firstName: '',
        lastName: '',
        email: '',
        phone: '',
        birthDate: ''
      },
      address: {
        street: '',
        city: '',
        state: '',
        zipCode: '',
        country: 'US'
      },
      preferences: {
        theme: 'light',
        emailNotifications: true,
        smsNotifications: false,
        language: 'en',
        currency: 'USD'
      }
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>Personal Information</h2>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input
          id="firstName"
          value={firstName}
          onChange={ev => handleChange('personal', 'firstName', ev.target.value)}
        />
      </div>
      {/* Other UI components */}
    </form>
  );
}
import { produce } from 'immer';

// ...

setUserProfile(profile => produce(profile, draft => {
  // Not really mutating!
  draft.personal.firstName = event.target.value;
}));
function CheckoutWizardBad() {
  const [isAddressStep, setIsAddressStep] = useState(true);
  const [isPaymentStep, setIsPaymentStep] = useState(false);
  const [isConfirmationStep, setIsConfirmationStep] = useState(false);
  const [isCompleteStep, setIsCompleteStep] = useState(false);
  
  const goToPayment = () => {
    setIsAddressStep(false);
    setIsPaymentStep(true);
    setIsConfirmationStep(false);
    setIsCompleteStep(false);
  };
  
  const goToConfirmation = () => {
    setIsAddressStep(false);
    setIsPaymentStep(false);
    setIsConfirmationStep(true);
    setIsCompleteStep(false);
  };
  
  // More transition functions...
  
  return (
    <div>
      {isAddressStep && <AddressForm onNext={goToPayment} />}
      {isPaymentStep && <PaymentForm onNext={goToConfirmation} />}
      {/* Other steps... */}
    </div>
  );
}
function CheckoutWizardGood() {
  const [currentStep, setCurrentStep] = useState('address');
  
  const goToStep = (step) => {
    setCurrentStep(step);
  };
  
  return (
    <div>
      {currentStep === 'address' && (
        <AddressForm onNext={() => goToStep('payment')} />
      )}
      {currentStep === 'payment' && (
        <PaymentForm onNext={() => goToStep('confirmation')} />
      )}
      {/* Other steps... */}
    </div>
  );
}

Overusing state

useState

  • Group related state together
  • Use Immer for complex state updates
  • Use string enums to reduce booleans
  • Think before vibe coding

Discriminated unions

useState

import { useEffect, useState } from "react";

function DataFetchExample() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchData = async () => {
    try {
      setLoading(true);
      setError(null);
      
      const response = await fetch('https://api.example.com/data');
      if (!response.ok) {
        throw new Error('Failed to fetch data');
      }
      
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return (
    <div>
      <button onClick={fetchData}>Refresh Data</button>
      
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error}</p>}
      {data && <p>Data: {JSON.stringify(data)}</p>}
    </div>
  );
}
import { useEffect, useState } from "react";

type DataStatus = 'idle' | 'loading' | 'success' | 'error';

function DataFetchExample() {
  const [status, setStatus] = useState<DataStatus>('idle');
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const fetchData = async () => {
    try {
      setStatus('loading');
      setError(null);
      
      const response = await fetch('https://api.example.com/data');
      if (!response.ok) {
        throw new Error('Failed to fetch data');
      }
      
      const result = await response.json();
      setStatus('success');
      setData(result);
    } catch (err) {
      setStatus('error');
      setError(err.message);
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return (
    <div>
      <button onClick={fetchData}>Refresh Data</button>
      
      {status === 'loading' && <p>Loading...</p>}
      {status === 'error' && <p>Error: {error}</p>}
      {status === 'success' && <p>Data: {JSON.stringify(data)}</p>}
    </div>
  );
}
import { useEffect, useState } from 'react';

type DataState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: any };

function DataFetchExample() {
  const [state, setState] = useState<DataState>({ status: 'idle' });

  const fetchData = async () => {
    setState({ status: 'loading' });

    try {
      const response = await fetch('https://api.example.com/data');
      if (!response.ok) {
        throw new Error('Failed to fetch data');
      }

      const result = await response.json();
      setState({ status: 'success', data: result });
    } catch (err) {
      setState({ status: 'error', error: err.message });
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  return (
    <div>
      <button onClick={fetchData}>Refresh Data</button>

      {state.status === 'loading' && <p>Loading...</p>}
      {state.status === 'error' && <p>Error: {state.error}</p>}
      {state.status === 'success' && <p>Data: {JSON.stringify(state.data)}</p>}
    </div>
  );
}

Discriminated
union

Discriminated unions

useState

  • Use string enums to reduce booleans
  • Use discriminated unions for type safety
  • Group related state in discriminated unions

Derived state

useState

function DonutOrder() {
  const [selectedDonuts, setSelectedDonuts] = useState([
    { id: 1, name: 'Glazed', price: 1.99, quantity: 2 },
    { id: 2, name: 'Chocolate', price: 2.49, quantity: 1 },
    { id: 3, name: 'Sprinkled', price: 2.29, quantity: 3 }
  ]);
  
  // Derived state - should be calculated directly!
  const [totalItems, setTotalItems] = useState(0);
  const [subtotal, setSubtotal] = useState(0);
  const [tax, setTax] = useState(0);
  const [total, setTotal] = useState(0);
  
  // Recalculate derived values whenever selectedDonuts changes
  useEffect(() => {
    const itemCount = selectedDonuts.reduce((sum, item) => sum + item.quantity, 0);
    const itemSubtotal = selectedDonuts.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    const itemTax = itemSubtotal * 0.08;
    const itemTotal = itemSubtotal + itemTax;
    
    setTotalItems(itemCount);
    setSubtotal(itemSubtotal);
    setTax(itemTax);
    setTotal(itemTotal);
  }, [selectedDonuts]);
  
  const updateQuantity = (id, newQuantity) => {
    setSelectedDonuts(
      selectedDonuts.map(donut => 
        donut.id === id ? { ...donut, quantity: newQuantity } : donut
      )
    );
    // The derived values will be updated by the useEffect
  };
  
  return (
    <div className="donut-order">
      <h2>Donut Order</h2>
      
      {selectedDonuts.map(donut => (
        <div key={donut.id} className="donut-item">
          <span>{donut.name} (${donut.price.toFixed(2)})</span>
          <input
            type="number"
            min="0"
            value={donut.quantity}
            onChange={(e) => updateQuantity(donut.id, parseInt(e.target.value))}
          />
        </div>
      ))}
      
      <div className="order-summary">
        <p>Items: {totalItems}</p>
        <p>Subtotal: ${subtotal.toFixed(2)}</p>
        <p>Tax: ${tax.toFixed(2)}</p>
        <p>Total: ${total.toFixed(2)}</p>
      </div>
    </div>
  );
}
function DonutOrder() {
  const [selectedDonuts, setSelectedDonuts] = useState([
    { id: 1, name: 'Glazed', price: 1.99, quantity: 2 },
    { id: 2, name: 'Chocolate', price: 2.49, quantity: 1 },
    { id: 3, name: 'Sprinkled', price: 2.29, quantity: 3 }
  ]);
  
  // Calculate all derived values directly during render - no useState or useEffect needed
  const totalItems = selectedDonuts.reduce((sum, item) => sum + item.quantity, 0);
  const subtotal = selectedDonuts.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  const tax = subtotal * 0.08; // 8% tax
  const total = subtotal + tax;
  
  const updateQuantity = (id, newQuantity) => {
    setSelectedDonuts(
      selectedDonuts.map(donut => 
        donut.id === id ? { ...donut, quantity: newQuantity } : donut
      )
    );
    // All derived values will be recalculated automatically on the next render
  };
  
  return (
    <div className="donut-order">
      <h2>Donut Order</h2>
      
      {selectedDonuts.map(donut => (
        <div key={donut.id} className="donut-item">
          <span>{donut.name} (${donut.price.toFixed(2)})</span>
          <input
            type="number"
            min="0"
            value={donut.quantity}
            onChange={(e) => updateQuantity(donut.id, parseInt(e.target.value))}
          />
        </div>
      ))}
      
      <div className="order-summary">
        <p>Items: {totalItems}</p>
        <p>Subtotal: ${subtotal.toFixed(2)}</p>
        <p>Tax: ${tax.toFixed(2)}</p>
        <p>Total: ${total.toFixed(2)}</p>
      </div>
    </div>
  );
}

Derived state

useState

useState only for primary state

Derive state directly in component

useMemo if you need to

Reducing state logic

useState

useReducer
useState
import { useReducer } from 'react';

function Component({ count }) {
  const [isActive, toggle] =
    useReducer(a => !a, true);

  return <>
    <output>{isActive
      ? 'oui'
      : 'non'
    }</output>
    <button onClick={toggle} />
  </>
}
const donutInventory = {
  chocolate: {
    quantity: 10,
    price: 1.5,
  },
  vanilla: {
    quantity: 10,
    price: 1.5,
  },
  strawberry: {
    quantity: 10,
    price: 1.5,
  },
};

type Donut = keyof typeof donutInventory;
import { useState } from 'react';

export function DonutShop() {
  const [donuts, setDonuts] = useState<Donut[]>([]);

  const addDonut = (donut: Donut) => {
    const orderedDonuts = donuts.filter((d) => d === donut).length;
    if (donutInventory[donut].quantity > orderedDonuts) {
      setDonuts([...donuts, donut]);
    }
  };

  const removeDonut = (donutIndex: number) => {
    setDonuts(donuts.filter((_, index) => index !== donutIndex));
  };

  const total = donuts.reduce(
    (acc, donut) => acc + donutInventory[donut].price,
    0
  );

  return (
    <div>
      <h1>Donut Shop</h1>
      <div>
        {Object.keys(donutInventory).map((donut) => (
          <div key={donut}>
            <button onClick={() => addDonut(donut as Donut)}>
              Add {donut}
            </button>
          </div>
        ))}
      </div>
      <div>
        {donuts.map((donut, index) => (
          <div key={donut}>
            <p>{donut}</p>
            <button onClick={() => removeDonut(index)}>Remove {donut}</button>
          </div>
        ))}
      </div>
      <div>
        <p>Total: {total}</p>
      </div>
    </div>
  );
}
import { useReducer } from 'react';

type Action = 
  | { type: 'ADD_DONUT'; donut: Donut }
  | { type: 'REMOVE_DONUT'; donut: Donut };

function donutReducer(state: Donut[], action: Action): Donut[] {
  switch (action.type) {
    case 'ADD_DONUT': {
      const orderedDonuts = state.filter(d => d === action.donut).length;
      if (donutInventory[action.donut].quantity > orderedDonuts) {
        return [...state, action.donut];
      }
      return state;
    }
    case 'REMOVE_DONUT':
      return state.filter((_, index) => index !== action.donutIndex);
    default:
      return state;
  }
}

export function DonutShop() {
  const [donuts, dispatch] = useReducer(donutReducer, []);

  const addDonut = (donut: Donut) => {
    dispatch({ type: 'ADD_DONUT', donut });
  };

  const removeDonut = (donutIndex: number) => {
    dispatch({ type: 'REMOVE_DONUT', donutIndex });
  };

  const total = donuts.reduce(
    (acc, donut) => acc + donutInventory[donut].price,
    0
  );

  return (
    <div>
      <h1>Donut Shop</h1>
      <div>
        {Object.keys(donutInventory).map((donut) => (
          <div key={donut}>
            <button onClick={() => addDonut(donut as Donut)}>
              Add {donut}
            </button>
          </div>
        ))}
      </div>
      <div>
        {donuts.map((donut, index) => (
          <div key={donut}>
            <p>{donut}</p>
            <button onClick={() => removeDonut(index)}>Remove {donut}</button>
          </div>
        ))}
      </div>
      <div>
        <p>Total: {total}</p>
      </div>
    </div>
  );
}
import { donutReducer } from './donutReducer';

describe('donutReducer', () => {
  it('adds a donut if inventory allows', () => {
    const newState = donutReducer([], {
      type: 'ADD_DONUT',
      donut: 'glazed'
    });
    
    expect(newState).toEqual(['glazed']);
  });
  
  // ...
});

Indirect

send(event)

Event-based

Direct

setState(value)

Value-based

Causality
What caused the change

Context
Parameters related to the change

Timing
When the change happened

Traceability
The ability to log or replay changes

Indirect

send(event)

Event-based

Direct state management is easy.


Indirect state management is simple.

Reducing state logic

useState

useReducer for complex UI logic and
interdependent state updates

Use reducer actions to constrain state updates

useReducer for maximum testability

Use events for better observability

Hydration mismatches

useState

'use client';

import { useState } from 'react';

export default function Today() {
  const [date] = useState(new Date());
  
  return <div><p>{date.toISOString()}</p></div>;
}
'use client';

import { useEffect, useState } from 'react';

export default function Today() {
  const [date, setDate] = useState(null);

  useEffect(() => {
    setDate(new Date());
  }, []);
  
  return <div>{date ? <p>{date.toISOString()}</p> : null}</div>;
}
'use client';

import { useSyncExternalStore } from 'react';

export default function Today() {
  const date = useSyncExternalStore(
    () => () => {},   // don't worry about this yet
    () => new Date(), // client snapshot
    () => null        // server snapshot
  );

  return <div>{date ? <p>{date.toISOString()}</p> : null}</div>;
}
'use client';

import { useSyncExternalStore } from 'react';

export default function Today() {
  const dateString = useSyncExternalStore(
    () => () => {},
    () => new Date().toISOString(),
    () => null
  );
  
  return <div>{date ? <p>{dateString}</p> : null}</div>;
}

Syncing with external stores

useState

useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot?
)
export default function TodosApp() {
  const todos = useSyncExternalStore(
    todosStore.subscribe,   // subscribe to store
    todosStore.getSnapshot, // client snapshot
    todosStore.getSnapshot  // server snapshot
  );

  return (
    <>
      <button onClick={() => todosStore.addTodo()}>
        Add todo
      </button>
      <hr />
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}
let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];

export const todosStore = {
  addTodo() {
    todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
    emitChange();
  },
  subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },
  getSnapshot() {
    return todos;
  }
};

function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}
import { useSyncExternalStore } from 'react';

function getSnapshot() {
  return {
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0
  };
}

function subscribe(callback) {
  window.addEventListener('resize', callback);
  return () => {
    window.removeEventListener('resize', callback);
  };
}

function WindowSizeIndicator() {
  const windowSize = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getSnapshot
  );
  
  return <h1>Window size: {windowSize.width} x {windowSize.height}</h1>;
}
import { useSyncExternalStore } from 'react';

function useWindowSize() {
  const getSnapshot = () => ({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0
  });

  const subscribe = (callback: () => void) => {
    window.addEventListener('resize', callback);
    return () => {
      window.removeEventListener('resize', callback);
    };
  };

  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getSnapshot
  );
}

function WindowSizeIndicator() {
  const windowSize = useWindowSize();
  
  return <h1>Window size: {windowSize.width} x {windowSize.height}</h1>;
}

Syncing with external stores

useState

useSyncExternalStore for preventing hydration mismatches

useSyncExternalStore for subscribing to browser APIs

useSyncExternalStore for external stores that don't already have
React integrations

Third-party libraries

useState

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <output>{count}</output>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={() => setCount(count - 1)}>Subtract</button>
    </section>
  );
}
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <output>{count}</output>
      <button
        onClick={() => {
          if (count < 10) setCount(count + 1);
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          if (count > 0) setCount(count - 1);
        }}
      >
        Subtract
      </button>
    </section>
  );
}
function Counter() {
  const [count, setCount] = useState(0);

  function increment(count) {
    if (count < 10) setCount(count + 1);
  }

  function decrement(count) {
    if (count > 0) setCount(count - 1);
  }

  return (
    <section>
      <output>{count}</output>
      <input
        type="number"
        onBlur={(e) => {
          setCount(e.target.valueAsNumber);
        }}
      />
      <button onClick={increment}>Add</button>
      <button onClick={decrement}>Subtract</button>
    </section>
  );
}
function Counter() {
  const [count, setCount] = useState(0);

  function changeCount(val) {
    if (val >= 0 && val <= 10) {
      setCount(val);
    }
  }

  return (
    <section>
      <output>{count}</output>
      <input
        type="number"
        onBlur={(e) => {
          changeCount(e.target.valueAsNumber);
        }}
      />
      <button
        onClick={(e) => {
          changeCount(count + 1);
        }}
      >
        Add
      </button>
      <button
        onClick={(e) => {
          changeCount(count - 1);
        }}
      >
        Subtract
      </button>
    </section>
  );
}
function Counter() {
  const [count, send] = useReducer((state, event) => {
    let currentCount = state;
    if (event.type === "inc") {
      currentCount = state + 1;
    }
    if (event.type === "dec") {
      currentCount = state - 1;
    }
    if (event.type === "set") {
      currentCount = event.value;
    }
    return Math.min(Math.max(0, currentCount), 10);
  }, 0);

  return (
    <section>
      <output>{count}</output>
      <input
        type="number"
        onBlur={(e) => {
          send({ type: "set", value: e.target.valueAsNumber });
        }}
      />
      <button
        onClick={() => {
          send({ type: "inc" });
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          send({ type: "dec" });
        }}
      >
        Subtract
      </button>
    </section>
  );
}
const CountContext = createContext();

function CountView() {
  const count = useContext(CountContext);

  return (
    <section>
      <strong>Count: {count}</strong>
      <button
        onClick={() => {
          // send({ type: "inc" });
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          // send({ type: "dec" });
        }}
      >
        Subtract
      </button>
    </section>
  );
}

function App() {
  const [count, send] = useReducer((state, event) => {
    let currentCount = state;
    if (event.type === "inc") {
      currentCount = state + 1;
    }
    if (event.type === "dec") {
      currentCount = state - 1;
    }
    if (event.type === "set") {
      currentCount = event.value;
    }
    return Math.min(Math.max(0, currentCount), 10);
  }, 0);

  return (
    <CountContext.Provider value={count}>
      <CountView />
    </CountContext.Provider>
  );
}
const CountContext = createContext();

function CountView() {
  const [count, send] = useContext(CountContext);

  return (
    <section>
      <strong>Count: {count}</strong>
      <button
        onClick={() => {
          send({ type: "inc" });
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          send({ type: "dec" });
        }}
      >
        Subtract
      </button>
    </section>
  );
}

export function App() {
  const [count, send] = useReducer((state, event) => {
    let currentCount = state;
    if (event.type === "inc") {
      currentCount = state + 1;
    }
    if (event.type === "dec") {
      currentCount = state - 1;
    }
    if (event.type === "set") {
      currentCount = event.value;
    }
    return Math.min(Math.max(0, currentCount), 10);
  }, 0);

  return (
    <CountContext.Provider value={[count, send]}>
      <CountView />
    </CountContext.Provider>
  );
}
const CountContext = createContext();

function CountView() {
  const countStore = useContext(CountContext);
  const [count, setCount] = useState(0);

  useEffect(() => {
    return countStore.subscribe(setCount);
  }, []);

  return (
    <section>
      <strong>Count: {count}</strong>
      <button
        onClick={() => {
          countStore.send({ type: "inc" });
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          countStore.send({ type: "dec" });
        }}
      >
        Subtract
      </button>
    </section>
  );
}

function useCount() {
  const [state, send] = useReducer((state, event) => {
    let currentCount = state;
    if (event.type === "inc") {
      currentCount = state + 1;
    }
    if (event.type === "dec") {
      currentCount = state - 1;
    }
    if (event.type === "set") {
      currentCount = event.value;
    }
    return Math.min(Math.max(0, currentCount), 10);
  }, 0);
  const listeners = useRef(new Set());

  useEffect(() => {
    listeners.current.forEach((listener) => listener(state));
  }, [state]);

  return {
    send,
    subscribe: (listener) => {
      listeners.current.add(listener);

      return () => {
        listeners.current.delete(listener);
      };
    }
  };
}

export function App() {
  const countStore = useCount();

  return (
    <CountContext.Provider value={countStore}>
      <CountView />
    </CountContext.Provider>
  );
}
const CountContext = createContext();

function useSelector(store, selectFn) {
  const [state, setState] = useState(store.getSnapshot());

  useEffect(() => {
    return store.subscribe((newState) => setState(selectFn(newState)));
  }, []);

  return state;
}

function CountView() {
  const countStore = useContext(CountContext);
  const count = useSelector(countStore, (count) => count);

  return (
    <section>
      <strong>Count: {count}</strong>
      <button
        onClick={() => {
          countStore.send({ type: "inc" });
        }}
      >
        Add
      </button>
      <button
        onClick={() => {
          countStore.send({ type: "dec" });
        }}
      >
        Subtract
      </button>
    </section>
  );
}

function useCount() {
  const [state, send] = useReducer((state, event) => {
    let currentCount = state;
    if (event.type === "inc") {
      currentCount = state + 1;
    }
    if (event.type === "dec") {
      currentCount = state - 1;
    }
    if (event.type === "set") {
      currentCount = event.value;
    }
    return Math.min(Math.max(0, currentCount), 10);
  }, 0);
  const listeners = useRef(new Set());

  useEffect(() => {
    listeners.current.forEach((listener) => listener(state));
  }, [state]);

  return {
    send,
    subscribe: (listener) => {
      listeners.current.add(listener);

      return () => {
        listeners.current.delete(listener);
      };
    },
    getSnapshot: () => state
  };
}

function App() {
  const countStore = useCount();

  return (
    <CountContext.Provider value={countStore}>
      <CountView />
    </CountContext.Provider>
  );
}

Congrats, you just reinvented

🎉

Direct store

Direct store

Store via Context

stately.ai/docs/xstate-store

Store

npm i @xstate/store
import { createStore } from '@xstate/store';

const store = createStore({
  context: {
    count: 0
  },
  on: {
    inc: (ctx) => ({
      ...ctx,
      count: ctx.count + 1
    })
  }
});

store.subscribe(s => {
  console.log(s.context.count);
});

store.trigger.inc();
// => 1

Store

npm i @xstate/store
import { useStore, useSelector } from '@xstate/store/react';

export function DonutShop() {
  const donutStore = useStore({
    context: {
      donuts: [] as Donut[],
    },
    on: {
      addDonut: (context, event: { donut: Donut }) => {
        const orderedDonuts = context.donuts.filter(
          (d) => d === event.donut
        ).length;
        if (donutInventory[event.donut].quantity > orderedDonuts) {
          return {
            donuts: [...context.donuts, event.donut],
          };
        }
      },
      removeDonut: (context, event: { donutIndex: number }) => {
        return {
          donuts: context.donuts.filter(
            (_, index) => index !== event.donutIndex
          ),
        };
      },
    },
  });
  const donuts = useSelector(donutStore, (state) => state.context.donuts);
  const total = donuts.reduce(
    (acc, donut) => acc + donutInventory[donut].price,
    0
  );

  return (
    <div>
      <h1>Donut Shop</h1>
      <div>
        {Object.keys(donutInventory).map((donut) => (
          <div key={donut}>
            <button
              onClick={() =>
                donutStore.trigger.addDonut({ donut: donut as Donut })
              }
            >
              Add {donut}
            </button>
          </div>
        ))}
      </div>
      <div>
        {donuts.map((donut, index) => (
          <div key={donut}>
            <p>{donut}</p>
            <button
              onClick={() =>
                donutStore.trigger.removeDonut({ donutIndex: index })
              }
            >
              Remove {donut}
            </button>
          </div>
        ))}
      </div>
      <div>
        <p>Total: {total}</p>
      </div>
    </div>
  );
}

less

import { useStore, useSelector } from '@xstate/store/react';

export function DonutShop() {
  const donutStore = useStore({
    context: {
      donuts: [] as Donut[],
    },
    emits: {
      outOfStock: (_: { donut: Donut }) => {},
    },
    on: {
      addDonut: (context, event: { donut: Donut }, enq) => {
        const orderedDonuts = context.donuts.filter(
          (d) => d === event.donut
        ).length;
        if (donutInventory[event.donut].quantity > orderedDonuts) {
          return {
            donuts: [...context.donuts, event.donut],
          };
        } else {
          enq.emit.outOfStock({ donut: event.donut });
        }
      },
      removeDonut: (context, event: { donutIndex: number }) => {
        // ...
      },
    },
  });

  useEffect(() => {
    const sub = donutStore.on('outOfStock', ({ donut }) => {
      alert(`Out of stock: ${donut}`);
    });
    
    return sub.unsubscribe;
  }, []);

  // ...
}
import { createStore } from '@xstate/store';
import { useStore, useSelector } from '@xstate/store/react';

export const donutStore = createStore({
  context: {
    donuts: [] as Donut[],
  },
  emits: {
    outOfStock: (_: { donut: Donut }) => {},
  },
  on: {
    addDonut: (context, event: { donut: Donut }, enq) => {
      // ...
    },
    removeDonut: (context, event: { donutIndex: number }) => {
      // ...
    },
  },
});

export function DonutShop() {
  const donuts = useSelector(donutStore, state => state.context.donuts);

  useEffect(() => {
    const sub = donutStore.on('outOfStock', ({ donut }) => {
      alert(`Out of stock: ${donut}`);
    });
    
    return sub.unsubscribe;
  }, []);

  // ...
}

Store

npm i @xstate/store

Effect management
is state management.

(state, event) => (nextState,        )
effects
(state, event) => nextState

🇺🇸 State transition

🇫🇷 coup d'État

Third-party libraries

useState

  • Use external stores for shared/global state
  • Use selectors to prevent rerenders
  • Make declarative effects whenever possible

Local-first state

useState

Fetch
data

Sync
data

import { useShape } from '@electric-sql/react'

const MyComponent = () => {
  const { isLoading, data } = useShape<{title: string}>({
    url: `http://localhost:3000/v1/shape`,
    params: {
      table: 'items'
    }
  })

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

  return (
    <div>
      {data.map(item => <div>{item.title}</div>)}
    </div>
  )
}

LiveStore

Local-first state

useState

  • Use local-first state solutions (sync engines)
    for offline-capable state & DB syncing
  • Use sync engines for realtime updates

useState

  • Use it for truly
    component-internal UI state
  • Ask yourself:
    is there a better pattern?

Not all state is UI state!

There usually is, especially with React 19!

  • useState
  • useRef
  • useReducer
  • useContext
  • useTransition
  • useActionState
  • useOptimistic
  • useSyncExternalStore
  • use RSCs
  • use query libraries
  • use 3rd-party stores
  • use local-first sync state

useState

Start with

Refactor to

better patterns

Focus on maintenance:

simple > easy

Merci React Paris!

David Khourshid · @davidkpiano
stately.ai

Goodbye, useState MN

By David Khourshid

Goodbye, useState MN

  • 73