Frontend Masters

Welcome to the workshop

Principle 1

Events are the most accurate representation of state

  • Everything else is a useful but lossy abstraction
  • Show: User clicked vs. button is disabled

Write code as if you're going to change frameworks

  • Socket/port architecture keeps code organized
  • Business logic separate from React-specific code

Pure functions are the easiest thing to test

  • All app logic should be in pure functions
  • Show: calculateTripTotal() example

Normalization is key; think twice before nesting

  • Flat data structures vs. deeply nested objects
  • Show: itinerary with nested destinations (bad) vs. normalized (good)

Think in state machines

  • They exist whether explicit or not
  • Show: booking flow implicit states vs. explicit state machine
    •  
  • Make side effects declarative
    • Separate what happens from when it happens
    • Show: imperative API call vs. declarative effect

Slide 8: How These Principles Connect

  • Visual diagram showing how they reinforce each other
  • Events → Pure Functions → State Machines → Declarative Effects

Reacting to input with state

https://react.dev/learn/reacting-to-input-with-state

Choosing the State Structure

https://react.dev/learn/choosing-the-state-structure

Sharing state between components

https://react.dev/learn/sharing-state-between-components

Extracting state logic into a reducer

https://react.dev/learn/extracting-state-logic-into-a-reducer

Setting up with reducer and context

https://react.dev/learn/scaling-up-with-reducer-and-context

Slide 9: The useState/useEffect Trap

  • Common patterns that seem fine but break at scale

Slide 10-12: useState Limitations

  • Race conditions in flight search
  • State synchronization issues
  • Multiple sources of truth

Slide 13-15: useEffect Pitfalls

  • Dependency array confusion
  • Cleanup function mistakes
  • Thinking in lifecycle vs. synchronization

Slide 16-17: Live Demo Setup

  • Introduce the travel app
  • Show the "before" version with useState problems

State Modeling & Visualization (6-8 slides)

 

Slide 18: Thinking Before Coding

  • Why we diagram first
  • Different types of state (UI, server, form, URL)
  •  

Slide 19-21: Diagramming Techniques

  • State diagrams for booking flow
  • Sequence diagrams for user interactions
  • ERDs for data relationships

State diagrams

Sequence diagrams

ERDs

Slide 22-24: From Diagrams to Code

  • How state diagrams become TypeScript types
  • Impossible states become impossible

Slide 25: Exercise Setup

  • "Draw the hotel booking state machine"

Event-Driven Architecture (8-10 slides)

 

Slide 26: Events vs. Direct State Manipulation

  • "What happened" vs. "Change this thing"
  • Event sourcing concepts

Slide 27-29: Event Examples in Travel App

  • FlightSelected vs. setSelectedFlight()
  • BookingFailed vs. setError() + setLoading(false)
  • CommentAdded vs. multiple state updates

Slide 30-32: Benefits of Events

  • Audit trail
  • Easier debugging
  • Decoupling components

Slide 33-35: Implementation Patterns

  • Event types with TypeScript
  • Event handlers as pure functions
  • Connecting events to state changes

Alternative State Approaches (6-8 slides)

 

Slide 36: When to Use What

  • Decision tree for state management approaches

Slide 37-40: The Alternatives

  • URL state (nuqs) - shareable state
  • Server Components - server-derived state
  • useReducer - complex local state
  • Global stores - shared application state

Slide 41-43: Live Coding Preview

  • Converting search filters to URL state
  • Comparing useState vs. useReducer for booking flow

Advanced Patterns (8-10 slides)

 

Slide 44-46: Server State Management

  • Optimistic updates
  • Error handling and rollback
  • Synchronizing multiple sources

Slide 47-49: Performance Patterns

  • State normalization for performance
  • Render optimization
  • When to memoize

Slide 50-52: Testing & Debugging

  • Testing pure state functions
  • State visualization tools
  • Time-travel debugging

Slide 53: Maintenance & Scaling

  • Adding features to existing state architecture
  • Refactoring patterns

Workshop Exercises & Wrap-up (3-5 slides)

 

Slide 54-55: Practice Challenges

  • Hands-on exercises for attendees
  • Solutions and discussion

Slide 56-57: Key Takeaways

  • The principles recap
  • Resources for continued learning

Who am I?

Doesn't matter

We have too much to talk about

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

Frontend Masters State

By David Khourshid

Frontend Masters State

  • 101