Principle 1
Events are the most accurate representation of state
Write code as if you're going to change frameworks
Pure functions are the easiest thing to test
Normalization is key; think twice before nesting
Think in state machines
Slide 8: How These Principles Connect
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
Slide 10-12: useState Limitations
Slide 13-15: useEffect Pitfalls
Slide 16-17: Live Demo Setup
Slide 18: Thinking Before Coding
Slide 19-21: Diagramming Techniques
State diagrams
Sequence diagrams
ERDs
Slide 22-24: From Diagrams to Code
Slide 25: Exercise Setup
Slide 26: Events vs. Direct State Manipulation
Slide 27-29: Event Examples in Travel App
FlightSelected vs. setSelectedFlight()
BookingFailed vs. setError() + setLoading(false)
CommentAdded vs. multiple state updatesSlide 30-32: Benefits of Events
Slide 33-35: Implementation Patterns
Slide 36: When to Use What
Slide 37-40: The Alternatives
Slide 41-43: Live Coding Preview
Slide 44-46: Server State Management
Slide 47-49: Performance Patterns
Slide 50-52: Testing & Debugging
Slide 53: Maintenance & Scaling
Slide 54-55: Practice Challenges
Slide 56-57: Key Takeaways
We have too much to talk about
useEffect()
useDefect()
useFoot(() => {
// 🦶🔫
setGun(true);
});
export const lightMachineSetup = setup({
// ...
});
export const lightMachine = lightMachineSetup.createMachine({
initial: 'green',
states: {
green: {
on: {
TIMER: 'yellow'
}
},
yellow: {
on: {
TIMER: 'red'
}
},
red: {
on: {
TIMER: 'green'
}
}
}
});export const lightMachineSetup = setup({
// ...
});
export const lightMachine = lightMachineSetup.createMachine({
initial: 'green',
states: {
green,
yellow,
red
}
});export const green = lightMachineSetup.createStateConfig({
on: {
TIMER: 'yellow'
}
});
export const yellow = lightMachineSetup.createStateConfig({
on: {
TIMER: 'red'
}
});
export const red = lightMachineSetup.createStateConfig({
on: {
TIMER: 'green'
}
});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 [count, setCount] = useState(0);
return (
<button onClick={() => {
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>
);🤷
function Component() {
const [value, setValue] = useState('bonjour');
return (
<Foo>
<h1>{value}</h1>
<Bar />
<Baz />
<Quo>
<Inner someProp={value} onChange={setValue} />
</Quo>
</Foo>
);
}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>
);
}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>
</>
);
}Use search params for URL-persisted state
Watch the next talk on nuqs
'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>
);
}I'll keep you in
Just read the docs:
react.dev/reference/react/Suspense
'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
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
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('');
// ...
}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>
);
}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
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>
);
}useState only for primary state
Derive state directly in component
useMemo if you need to
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']);
});
// ...
});send(event)Event-based
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
send(event)Event-based
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
'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>;
}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>;
}useSyncExternalStore for preventing hydration mismatches
useSyncExternalStore for subscribing to browser APIs
useSyncExternalStore for external stores that don't already have
React integrations
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
🎉
stately.ai/docs/xstate-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();
// => 1npm 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;
}, []);
// ...
}npm i @xstate/store
(state, event) => (nextState, )
effects
(state, event) => nextState
🇺🇸 State transition
🇫🇷 coup d'État
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
Not all state is UI state!
There usually is, especially with React 19!
useState
useRef
useReducer
useContext
useTransition
useActionState
useOptimistic
useSyncExternalStore
David Khourshid · @davidkpiano
stately.ai