"Stop guessing. State management and local storage aren't the same thing. Here's why it matters and how to pick the right solution for your app."
The Problem That Happens in Every Project
Picture this: you've got a component that needs to save data. So you think "let me just throw this in local storage." You write the code, it compiles... but it doesn't work. The page doesn't update. The user clicks the button a million times. Nothing happens.
Why? Because local storage isn't state management. It's something completely different.
Here's the thing: most developers mix them up. They think local storage solves state management problems. It doesn't. Each one solves a different problem.
The Core Difference
State management tells React "the data changed, re-render." Local storage tells the browser "save this for later."
One is about reactivity. The other is about persistence.
// ❌ This is wrong - local storage without state
function Modal() {
const isOpen = localStorage.getItem('isOpen') === 'true'
const handleToggle = () => {
localStorage.setItem('isOpen', !isOpen)
}
return isOpen ? <div>...</div> : null
}
See what's happening? You're saving to local storage. But React doesn't know the data changed. The page doesn't update. The user sees the same thing.
// ✅ This is right - state with local storage
function Modal() {
const [isOpen, setIsOpen] = useState(() => {
return localStorage.getItem('isOpen') === 'true'
})
const handleToggle = () => {
const newValue = !isOpen
setIsOpen(newValue)
localStorage.setItem('isOpen', newValue)
}
return isOpen ? <div>...</div> : null
}
The difference? We used useState. That tells React "the data changed." Then we saved to local storage. Both together.
The Full Spectrum: From Simple to Complex
Local State → Lifted State → Context → Global Store → Persistence
↓ ↓ ↓ ↓ ↓
useState Props pass Context Zustand localStorage
Each one has its place. Each one has its use.
1. Local State: The Simple Start
When to use: Your data only lives in one component. No other component needs to know about it.
function Dropdown() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>
Click me
</button>
{isOpen && <ul>...</ul>}
</>
)
}
Benefits:
- Simplest solution
- No prop drilling
- Each instance has its own state
- Best performance
When not to use: When another component needs the same data.
2. Lifting State Up: When You Need to Share
When to use: Two or more components need the same data.
function Parent() {
const [count, setCount] = useState(0)
return (
<>
<Counter count={count} setCount={setCount} />
<Display count={count} />
</>
)
}
Benefits:
- Simple
- No external libraries
Drawbacks:
- Prop drilling - you pass props everywhere
- All components re-render when data changes
- Unrelated components re-render unnecessarily
When not to use: When you have a deep component tree.
3. Context API: The Middle Ground
When to use: You need to pass data to many components without prop drilling.
const ThemeContext = createContext()
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
function Button() {
const { theme } = useContext(ThemeContext)
return <button className={theme}>Click</button>
}
Benefits:
- No prop drilling
- You can scope it - not every component needs to know
- Native to React
Drawbacks:
- Any component using the context re-renders when anything in it changes
- You can't selectively subscribe to one value
When to use it: Theme, auth, locale - things that don't change often.
4. Zustand: The General Solution
When to use: You need truly global state. You can even use it outside React.
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
function Counter() {
const { count, increment } = useStore()
return <button onClick={increment}>{count}</button>
}
Benefits:
- Truly global
- You can use it outside React
- You can subscribe to just one value - only that component re-renders
- Dead simple
When to use it: Shopping cart, user auth, global UI state.
5. Local Storage: Permanent Saving
When to use: You need data to stick around after the user closes the browser.
The important truth: Local storage alone isn't enough. You always need to pair it with state.
// ❌ This is wrong
function Component() {
const data = localStorage.getItem('data')
return <div>{data}</div>
}
// ✅ This is right
function Component() {
const [data, setData] = useState(() => {
return localStorage.getItem('data') || ''
})
const handleChange = (newData) => {
setData(newData)
localStorage.setItem('data', newData)
}
return <input value={data} onChange={(e) => handleChange(e.target.value)} />
}
When to use it:
- User preferences (theme, language)
- Form drafts
- Shopping cart
- Anything that should survive a refresh
When not to use it:
- Sensitive data (use cookies with httpOnly instead)
- Large amounts of data (localStorage is limited ~5MB)
- As a replacement for state management
Comparison Table
| Need | Local State | Lifted State | Context | Zustand | + localStorage |
|---|---|---|---|---|---|
| Single component | ✅ | ❌ | ❌ | ❌ | ❌ |
| Sibling components | ❌ | ✅ | ✅ | ✅ | ✅ |
| Deep tree | ❌ |
Real-World Example: A Shopping App
You've got:
- Theme (light/dark) - Context
- User auth - Zustand
- Shopping cart - Zustand + localStorage
- Modal open/close - Local state
- Form inputs - Local state
// Theme - Context
const ThemeContext = createContext()
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Auth & Cart - Zustand
const useStore = create((set) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
}))
// Persist cart to localStorage
const usePersistedCart = create(
persist(
(set) => ({
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
}),
{ name: 'cart-storage' }
)
)
// Modal - Local state
function ProductModal() {
const [isOpen, setIsOpen] = useState(false)
return isOpen ? <Modal onClose={() => setIsOpen(false)} /> : null
}
// Form - Local state
function CheckoutForm() {
const [email, setEmail] = useState('')
const [address, setAddress] = useState('')
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={address} onChange={(e) => setAddress(e.target.value)} />
</form>
)
}
Best Practices
- Start with local state - simplest solution first
- Lift state when you need to - only when another component needs it
- Use Context for semi-global - theme, auth
- Use Zustand for global - shopping cart, UI state
- Add localStorage when you need persistence - always with state
The Bottom Line
- Local state = one component
- Lifted state = sibling components
- Context = semi-global, scoped
- Zustand = truly global
- Local storage = persistence (always with state)
Pick the simplest solution that solves your problem. Don't use Zustand if local state works. Don't use local storage without state.
Sources:
Happy Coding! — Ahmed Fahmy