Back to Blog
ReactStatePatternsComponents

React State Lifting Patterns

Master React state lifting. From sibling communication to inverse data flow to shared state patterns.

B
Bootspring Team
Engineering
June 30, 2020
7 min read

Lifting state enables component communication through a common ancestor. Here's how to do it effectively.

Basic State Lifting#

1// Problem: Two components need to share state 2function TemperatureInput({ scale, value, onChange }) { 3 return ( 4 <div> 5 <label>Temperature in {scale}:</label> 6 <input 7 value={value} 8 onChange={(e) => onChange(e.target.value)} 9 /> 10 </div> 11 ); 12} 13 14// Solution: Lift state to common parent 15function TemperatureCalculator() { 16 const [celsius, setCelsius] = useState(''); 17 18 const fahrenheit = celsius !== '' 19 ? (parseFloat(celsius) * 9) / 5 + 32 20 : ''; 21 22 const handleCelsiusChange = (value: string) => { 23 setCelsius(value); 24 }; 25 26 const handleFahrenheitChange = (value: string) => { 27 const c = value !== '' ? ((parseFloat(value) - 32) * 5) / 9 : ''; 28 setCelsius(String(c)); 29 }; 30 31 return ( 32 <div> 33 <TemperatureInput 34 scale="Celsius" 35 value={celsius} 36 onChange={handleCelsiusChange} 37 /> 38 <TemperatureInput 39 scale="Fahrenheit" 40 value={String(fahrenheit)} 41 onChange={handleFahrenheitChange} 42 /> 43 </div> 44 ); 45}

Sibling Communication#

1// Two siblings need to communicate 2interface Product { 3 id: string; 4 name: string; 5 price: number; 6} 7 8// Parent holds shared state 9function ProductPage() { 10 const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); 11 const [products] = useState<Product[]>([ 12 { id: '1', name: 'Widget', price: 10 }, 13 { id: '2', name: 'Gadget', price: 20 }, 14 ]); 15 16 return ( 17 <div className="product-page"> 18 <ProductList 19 products={products} 20 selectedId={selectedProduct?.id} 21 onSelect={setSelectedProduct} 22 /> 23 <ProductDetails product={selectedProduct} /> 24 </div> 25 ); 26} 27 28// Child receives data and callbacks 29function ProductList({ 30 products, 31 selectedId, 32 onSelect, 33}: { 34 products: Product[]; 35 selectedId?: string; 36 onSelect: (product: Product) => void; 37}) { 38 return ( 39 <ul> 40 {products.map((product) => ( 41 <li 42 key={product.id} 43 className={product.id === selectedId ? 'selected' : ''} 44 onClick={() => onSelect(product)} 45 > 46 {product.name} 47 </li> 48 ))} 49 </ul> 50 ); 51} 52 53function ProductDetails({ product }: { product: Product | null }) { 54 if (!product) return <p>Select a product</p>; 55 56 return ( 57 <div> 58 <h2>{product.name}</h2> 59 <p>Price: ${product.price}</p> 60 </div> 61 ); 62}

Form State Lifting#

1// Lift form state for validation and submission 2interface FormData { 3 email: string; 4 password: string; 5} 6 7function LoginPage() { 8 const [formData, setFormData] = useState<FormData>({ 9 email: '', 10 password: '', 11 }); 12 const [errors, setErrors] = useState<Partial<FormData>>({}); 13 const [isSubmitting, setIsSubmitting] = useState(false); 14 15 const updateField = (field: keyof FormData, value: string) => { 16 setFormData((prev) => ({ ...prev, [field]: value })); 17 // Clear error when user types 18 if (errors[field]) { 19 setErrors((prev) => ({ ...prev, [field]: undefined })); 20 } 21 }; 22 23 const handleSubmit = async () => { 24 const newErrors: Partial<FormData> = {}; 25 if (!formData.email) newErrors.email = 'Email required'; 26 if (!formData.password) newErrors.password = 'Password required'; 27 28 if (Object.keys(newErrors).length > 0) { 29 setErrors(newErrors); 30 return; 31 } 32 33 setIsSubmitting(true); 34 await submitLogin(formData); 35 setIsSubmitting(false); 36 }; 37 38 return ( 39 <div> 40 <EmailInput 41 value={formData.email} 42 error={errors.email} 43 onChange={(value) => updateField('email', value)} 44 /> 45 <PasswordInput 46 value={formData.password} 47 error={errors.password} 48 onChange={(value) => updateField('password', value)} 49 /> 50 <SubmitButton 51 isSubmitting={isSubmitting} 52 onClick={handleSubmit} 53 /> 54 </div> 55 ); 56} 57 58function EmailInput({ 59 value, 60 error, 61 onChange, 62}: { 63 value: string; 64 error?: string; 65 onChange: (value: string) => void; 66}) { 67 return ( 68 <div> 69 <input 70 type="email" 71 value={value} 72 onChange={(e) => onChange(e.target.value)} 73 className={error ? 'error' : ''} 74 /> 75 {error && <span className="error-message">{error}</span>} 76 </div> 77 ); 78}

Controlled vs Uncontrolled#

1// Controlled: Parent owns state 2function ControlledSearch({ value, onChange }: { 3 value: string; 4 onChange: (value: string) => void; 5}) { 6 return ( 7 <input 8 value={value} 9 onChange={(e) => onChange(e.target.value)} 10 /> 11 ); 12} 13 14// Uncontrolled: Component owns state, reports changes 15function UncontrolledSearch({ onSearch }: { 16 onSearch: (query: string) => void; 17}) { 18 const [value, setValue] = useState(''); 19 20 const handleSubmit = () => { 21 onSearch(value); 22 }; 23 24 return ( 25 <div> 26 <input 27 value={value} 28 onChange={(e) => setValue(e.target.value)} 29 /> 30 <button onClick={handleSubmit}>Search</button> 31 </div> 32 ); 33} 34 35// Hybrid: Internal state with external sync 36function HybridInput({ 37 defaultValue = '', 38 onCommit, 39}: { 40 defaultValue?: string; 41 onCommit: (value: string) => void; 42}) { 43 const [value, setValue] = useState(defaultValue); 44 45 const handleBlur = () => { 46 onCommit(value); 47 }; 48 49 return ( 50 <input 51 value={value} 52 onChange={(e) => setValue(e.target.value)} 53 onBlur={handleBlur} 54 /> 55 ); 56}

Callback Patterns#

1// Pass callbacks for child-to-parent communication 2interface Todo { 3 id: string; 4 text: string; 5 completed: boolean; 6} 7 8function TodoApp() { 9 const [todos, setTodos] = useState<Todo[]>([]); 10 11 const addTodo = (text: string) => { 12 setTodos((prev) => [ 13 ...prev, 14 { id: Date.now().toString(), text, completed: false }, 15 ]); 16 }; 17 18 const toggleTodo = (id: string) => { 19 setTodos((prev) => 20 prev.map((todo) => 21 todo.id === id ? { ...todo, completed: !todo.completed } : todo 22 ) 23 ); 24 }; 25 26 const deleteTodo = (id: string) => { 27 setTodos((prev) => prev.filter((todo) => todo.id !== id)); 28 }; 29 30 return ( 31 <div> 32 <TodoForm onAdd={addTodo} /> 33 <TodoList 34 todos={todos} 35 onToggle={toggleTodo} 36 onDelete={deleteTodo} 37 /> 38 </div> 39 ); 40} 41 42function TodoForm({ onAdd }: { onAdd: (text: string) => void }) { 43 const [text, setText] = useState(''); 44 45 const handleSubmit = (e: React.FormEvent) => { 46 e.preventDefault(); 47 if (text.trim()) { 48 onAdd(text.trim()); 49 setText(''); 50 } 51 }; 52 53 return ( 54 <form onSubmit={handleSubmit}> 55 <input 56 value={text} 57 onChange={(e) => setText(e.target.value)} 58 placeholder="Add todo..." 59 /> 60 <button type="submit">Add</button> 61 </form> 62 ); 63} 64 65function TodoList({ 66 todos, 67 onToggle, 68 onDelete, 69}: { 70 todos: Todo[]; 71 onToggle: (id: string) => void; 72 onDelete: (id: string) => void; 73}) { 74 return ( 75 <ul> 76 {todos.map((todo) => ( 77 <TodoItem 78 key={todo.id} 79 todo={todo} 80 onToggle={() => onToggle(todo.id)} 81 onDelete={() => onDelete(todo.id)} 82 /> 83 ))} 84 </ul> 85 ); 86}

Avoiding Prop Drilling#

1// Problem: Deep prop drilling 2function App() { 3 const [user, setUser] = useState<User | null>(null); 4 5 return ( 6 <Layout user={user}> 7 <Sidebar user={user}> 8 <Navigation user={user}> 9 <UserMenu user={user} onLogout={() => setUser(null)} /> 10 </Navigation> 11 </Sidebar> 12 </Layout> 13 ); 14} 15 16// Solution 1: Component composition 17function App() { 18 const [user, setUser] = useState<User | null>(null); 19 20 const userMenu = user ? ( 21 <UserMenu user={user} onLogout={() => setUser(null)} /> 22 ) : ( 23 <LoginButton /> 24 ); 25 26 return ( 27 <Layout> 28 <Sidebar> 29 <Navigation userMenu={userMenu} /> 30 </Sidebar> 31 </Layout> 32 ); 33} 34 35// Solution 2: Context for truly global state 36const UserContext = createContext<{ 37 user: User | null; 38 setUser: (user: User | null) => void; 39} | null>(null); 40 41function App() { 42 const [user, setUser] = useState<User | null>(null); 43 44 return ( 45 <UserContext.Provider value={{ user, setUser }}> 46 <Layout> 47 <Sidebar> 48 <Navigation> 49 <UserMenu /> 50 </Navigation> 51 </Sidebar> 52 </Layout> 53 </UserContext.Provider> 54 ); 55} 56 57function UserMenu() { 58 const context = useContext(UserContext); 59 if (!context) throw new Error('UserContext not found'); 60 61 const { user, setUser } = context; 62 63 if (!user) return <LoginButton />; 64 65 return ( 66 <div> 67 <span>{user.name}</span> 68 <button onClick={() => setUser(null)}>Logout</button> 69 </div> 70 ); 71}

State Colocation#

1// Keep state as close to where it's used as possible 2 3// BAD: State lifted too high 4function App() { 5 const [searchQuery, setSearchQuery] = useState(''); 6 const [isModalOpen, setIsModalOpen] = useState(false); 7 const [selectedTab, setSelectedTab] = useState('home'); 8 9 return ( 10 <div> 11 <SearchBar query={searchQuery} onChange={setSearchQuery} /> 12 <Tabs selected={selectedTab} onChange={setSelectedTab} /> 13 <Modal open={isModalOpen} onClose={() => setIsModalOpen(false)} /> 14 </div> 15 ); 16} 17 18// GOOD: State colocated with usage 19function App() { 20 return ( 21 <div> 22 <SearchBar /> 23 <Tabs /> 24 <ModalContainer /> 25 </div> 26 ); 27} 28 29function SearchBar() { 30 const [query, setQuery] = useState(''); 31 return <input value={query} onChange={(e) => setQuery(e.target.value)} />; 32} 33 34function Tabs() { 35 const [selected, setSelected] = useState('home'); 36 return ( 37 <div> 38 {['home', 'about', 'contact'].map((tab) => ( 39 <button 40 key={tab} 41 className={selected === tab ? 'active' : ''} 42 onClick={() => setSelected(tab)} 43 > 44 {tab} 45 </button> 46 ))} 47 </div> 48 ); 49}

Best Practices#

When to Lift State: ✓ Multiple components need the same data ✓ Sibling components need to communicate ✓ Parent needs to coordinate children ✓ Data needs to be synchronized When NOT to Lift: ✓ Only one component uses the state ✓ State is purely UI (hover, focus) ✓ Component is self-contained ✓ Would cause prop drilling Patterns: ✓ Keep state as low as possible ✓ Lift only when necessary ✓ Use callbacks for upward communication ✓ Use composition to avoid drilling Performance: ✓ Memoize callbacks with useCallback ✓ Split state to minimize re-renders ✓ Consider Context for deep trees ✓ Use React.memo strategically

Conclusion#

State lifting is fundamental to React's data flow. Lift state to the lowest common ancestor that needs it, use callbacks for child-to-parent communication, and consider composition patterns to avoid prop drilling. For deeply nested state, consider Context or state management libraries.

Share this article

Help spread the word about Bootspring