Jotai Saved My Sanity (And My State): A Love Letter to Atomic State Management
Friends, Romans, countrymen, lend me your ears. I come to bury prop drilling pain, not to praise it. The evil that developers do with state management lives after them; the good that Jotai does is oft interred with their sanity. So let it be with React Context nightmares.
If you've ever built a complex React application—and I mean a real complex app, not some cutesy todo list with three components—you know the existential dread I'm talking about. You know the feeling of staring at 500 lines of Context providers and prop drilling, wondering where your life went wrong, questioning whether you should have become a barista instead.
Well, grab your favorite caffeinated beverage, because I'm here to tell you about Jotai, and how it turned my state management experience from "slowly descending into madness" to "actually enjoyable."
The Dark Ages: Before Jotai
Let me paint you a picture of the hellscape that was my life before Jotai. Picture, if you will, a trading dashboard for a FinTech application. Simple enough, right? Just show some charts, display user positions, and move on with your life.
narrator voice: It was not simple
Here's what this "simple" dashboard required:
- User Authentication: Login state, user preferences, permissions
- Market Data: Real-time prices, historical charts, watchlists
- Portfolio State: Positions, orders, account balances
- UI State: Modal visibility, selected tabs, loading states
- Notifications: Alerts, trade confirmations, system messages
- Settings: Theme preferences, notification settings, chart configurations
Oh, and did I mention it all had to be reactive? When market data updates, portfolios recalculate, notifications trigger, and UI elements rerender. When a user places an order, multiple components need to know about it instantly. Because apparently, I enjoy suffering.
Here's what my pre-Jotai state management looked like:
// The stuff of nightmares: Context Hell
const AppProvider = ({ children }) => {
// User state
const [user, setUser] = useState(null)
const [userPreferences, setUserPreferences] = useState({})
const [userPermissions, setUserPermissions] = useState([])
// Market data state
const [marketData, setMarketData] = useState({})
const [watchlist, setWatchlist] = useState([])
const [priceHistory, setPriceHistory] = useState({})
// Portfolio state
const [positions, setPositions] = useState([])
const [orders, setOrders] = useState([])
const [accountBalance, setAccountBalance] = useState(0)
const [portfolioValue, setPortfolioValue] = useState(0)
// UI state (the madness begins)
const [isTradeModalOpen, setIsTradeModalOpen] = useState(false)
const [selectedTab, setSelectedTab] = useState('portfolio')
const [isLoadingPortfolio, setIsLoadingPortfolio] = useState(false)
const [isLoadingMarketData, setIsLoadingMarketData] = useState(false)
const [chartTimeframe, setChartTimeframe] = useState('1D')
// Notification state
const [notifications, setNotifications] = useState([])
const [unreadCount, setUnreadCount] = useState(0)
// Settings state
const [theme, setTheme] = useState('dark')
const [notificationSettings, setNotificationSettings] = useState({})
// The horror: updating portfolio value when positions change
useEffect(() => {
if (positions.length > 0 && Object.keys(marketData).length > 0) {
let totalValue = 0
positions.forEach(position => {
const currentPrice = marketData[position.symbol]?.price || position.avgCost
totalValue += position.quantity * currentPrice
})
setPortfolioValue(totalValue)
// Oh wait, we also need to update account balance
const unrealizedPnL = totalValue - positions.reduce((sum, p) => sum + (p.quantity * p.avgCost), 0)
setAccountBalance(prev => prev + unrealizedPnL)
// And trigger notifications if there's a big change
const prevValue = portfolioValue
if (Math.abs(totalValue - prevValue) > 1000) {
setNotifications(prev => [...prev, {
id: Date.now(),
message: `Portfolio value changed by $${(totalValue - prevValue).toFixed(2)}`,
type: totalValue > prevValue ? 'positive' : 'negative'
}])
setUnreadCount(prev => prev + 1)
}
}
}, [positions, marketData, portfolioValue]) // This dependency array is a lie
// More useEffects for other cross-dependencies...
// I'm not kidding, there were 23 useEffects in this component
return (
<UserContext.Provider value={{ user, setUser, userPreferences, setUserPreferences }}>
<MarketDataContext.Provider value={{ marketData, setMarketData, watchlist, setWatchlist }}>
<PortfolioContext.Provider value={{ positions, setPositions, orders, setOrders }}>
<UIContext.Provider value={{ isTradeModalOpen, setIsTradeModalOpen, selectedTab }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
<SettingsContext.Provider value={{ theme, setTheme }}>
{children}
</SettingsContext.Provider>
</NotificationContext.Provider>
</UIContext.Provider>
</PortfolioContext.Provider>
</MarketDataContext.Provider>
</UserContext.Provider>
)
}
// And then, deep in some component:
function TradeButton({ symbol }) {
const { user } = useContext(UserContext)
const { marketData } = useContext(MarketDataContext)
const { setPositions } = useContext(PortfolioContext)
const { setIsTradeModalOpen } = useContext(UIContext)
const { setNotifications } = useContext(NotificationContext)
// Can't forget to check if contexts exist!
if (!user || !marketData || !setPositions || !setIsTradeModalOpen) {
throw new Error('TradeButton must be used within providers')
}
// This function updates 5 different contexts
const handleTrade = () => {
// Update positions context
setPositions(prev => [...prev, newPosition])
// Update notifications context
setNotifications(prev => [...prev, tradeNotification])
// Update UI context
setIsTradeModalOpen(false)
// Oh wait, I also need to update market data?
// But that's in a different context...
// *existential crisis intensifies*
}
}
This component was longer than the terms of service I never read and about as maintainable. Every time a new feature was added (spoiler alert: features were added daily), I had to navigate this labyrinth of nested providers, praying I didn't break something else.
Testing this? nervous laughter Good luck mocking 15 different contexts and ensuring they all work together. I had test files that looked like they were setting up a space mission.
Enter Jotai: The Atomic State Messiah
Then, like a divine intervention from the React gods themselves, I discovered Jotai. At first, I was skeptical. I've been hurt before by state management libraries that promised the world and delivered disappointment. But Jotai... Jotai was different.
Here's that same state management, but with Jotai:
import { atom, useAtom } from 'jotai'
// Define atomic pieces of state
const userAtom = atom(null)
const userPreferencesAtom = atom({})
const marketDataAtom = atom({})
const watchlistAtom = atom([])
const positionsAtom = atom([])
const ordersAtom = atom([])
const accountBalanceAtom = atom(0)
const isTradeModalOpenAtom = atom(false)
const selectedTabAtom = atom('portfolio')
const notificationsAtom = atom([])
const themeAtom = atom('dark')
// Derived atoms (computed values that auto-update)
const portfolioValueAtom = atom((get) => {
const positions = get(positionsAtom)
const marketData = get(marketDataAtom)
return positions.reduce((total, position) => {
const currentPrice = marketData[position.symbol]?.price || position.avgCost
return total + (position.quantity * currentPrice)
}, 0)
})
const unrealizedPnLAtom = atom((get) => {
const positions = get(positionsAtom)
const portfolioValue = get(portfolioValueAtom)
const totalCost = positions.reduce((sum, p) => sum + (p.quantity * p.avgCost), 0)
return portfolioValue - totalCost
})
const unreadNotificationsAtom = atom((get) => {
const notifications = get(notificationsAtom)
return notifications.filter(n => !n.read).length
})
// Write-only atom for complex actions
const addTradeAtom = atom(null, (get, set, trade) => {
// Update positions
const currentPositions = get(positionsAtom)
const newPositions = [...currentPositions, trade]
set(positionsAtom, newPositions)
// Add notification
const notifications = get(notificationsAtom)
set(notificationsAtom, [...notifications, {
id: Date.now(),
message: `Trade executed: ${trade.quantity} shares of ${trade.symbol}`,
type: 'trade',
read: false
}])
// Close modal
set(isTradeModalOpenAtom, false)
// All related state updates happen atomically!
})
// Your components become beautifully simple
function TradeButton({ symbol }) {
const [marketData] = useAtom(marketDataAtom)
const [, addTrade] = useAtom(addTradeAtom)
const handleTrade = () => {
const trade = {
symbol,
quantity: 100,
price: marketData[symbol]?.price,
timestamp: Date.now()
}
addTrade(trade) // One action, multiple state updates
}
return <button onClick={handleTrade}>Buy {symbol}</button>
}
function PortfolioValue() {
const [portfolioValue] = useAtom(portfolioValueAtom)
const [unrealizedPnL] = useAtom(unrealizedPnLAtom)
// These values automatically update when positions or market data change
return (
<div>
<h3>Portfolio Value: ${portfolioValue.toFixed(2)}</h3>
<p>Unrealized P&L: ${unrealizedPnL.toFixed(2)}</p>
</div>
)
}
function NotificationBadge() {
const [unreadCount] = useAtom(unreadNotificationsAtom)
if (unreadCount === 0) return null
return <span className="badge">{unreadCount}</span>
}
Look at that beauty! It's readable! It's maintainable! It doesn't make me question my life choices!
The Magic of Atomic Dependencies
But here's where Jotai really shines—atomic dependencies. Remember that nightmare of cross-component state updates? With Jotai, you can build state that automatically reacts and updates across your entire app:
// Complex derived state that updates automatically
const riskMetricsAtom = atom((get) => {
const positions = get(positionsAtom)
const marketData = get(marketDataAtom)
const userPreferences = get(userPreferencesAtom)
// Calculate portfolio risk based on multiple factors
const volatility = calculateVolatility(positions, marketData)
const concentration = calculateConcentration(positions)
const leverage = calculateLeverage(positions, get(accountBalanceAtom))
const riskScore = (volatility * 0.4) + (concentration * 0.3) + (leverage * 0.3)
const riskLevel = riskScore > userPreferences.riskTolerance ? 'HIGH' : 'NORMAL'
return {
volatility,
concentration,
leverage,
riskScore,
riskLevel
}
})
// Risk alerts that trigger automatically
const riskAlertsAtom = atom((get) => {
const riskMetrics = get(riskMetricsAtom)
const notifications = get(notificationsAtom)
// Don't create duplicate alerts
const existingRiskAlerts = notifications.filter(n => n.type === 'risk')
if (riskMetrics.riskLevel === 'HIGH' && existingRiskAlerts.length === 0) {
return [{
id: `risk-${Date.now()}`,
type: 'risk',
message: `Portfolio risk is elevated (${riskMetrics.riskScore.toFixed(2)})`,
severity: 'warning',
timestamp: Date.now()
}]
}
return []
})
// Auto-trigger risk alerts when risk changes
const riskMonitorAtom = atom(null, (get, set) => {
const newAlerts = get(riskAlertsAtom)
if (newAlerts.length > 0) {
const currentNotifications = get(notificationsAtom)
set(notificationsAtom, [...currentNotifications, ...newAlerts])
}
})
// Component that automatically shows risk warnings
function RiskIndicator() {
const [riskMetrics] = useAtom(riskMetricsAtom)
// This component automatically updates when any dependency changes:
// positions, market data, account balance, or user preferences
return (
<div className={`risk-indicator ${riskMetrics.riskLevel.toLowerCase()}`}>
<h4>Portfolio Risk: {riskMetrics.riskLevel}</h4>
<div>Volatility: {(riskMetrics.volatility * 100).toFixed(1)}%</div>
<div>Concentration: {(riskMetrics.concentration * 100).toFixed(1)}%</div>
<div>Leverage: {riskMetrics.leverage.toFixed(2)}x</div>
</div>
)
}
This is state management that actually understands your business logic. When market data updates, risk metrics recalculate automatically. When positions change, all related components update. When risk thresholds are breached, alerts trigger without any manual coordination.
"But Cary," You Whine, "Context is Simpler!"
Oh, sweet summer child. You think Context is simpler? Let me ask you this: when was the last time you confidently added a new piece of global state without breaking something? I'll wait.
Here's what "simple" Context looks like in the real world:
// The "simple" Context version
const TradingContext = createContext()
function TradingProvider({ children }) {
const [positions, setPositions] = useState([])
const [marketData, setMarketData] = useState({})
const [portfolioValue, setPortfolioValue] = useState(0)
// Oh no, I need to calculate portfolio value when positions change
useEffect(() => {
const newValue = positions.reduce((total, position) => {
const price = marketData[position.symbol]?.price || position.avgCost
return total + (position.quantity * price)
}, 0)
setPortfolioValue(newValue)
}, [positions, marketData])
// Wait, now I need notifications when value changes significantly
const [notifications, setNotifications] = useState([])
useEffect(() => {
// This is getting complicated...
if (/* some complex condition */) {
setNotifications(prev => [...prev, /* new notification */])
}
}, [portfolioValue]) // Is this dependency array correct? Who knows!
// More useEffects for other interdependencies...
// Each one a potential source of bugs and infinite loops
}
Versus the Jotai version where dependencies are explicit and automatic:
// Dependencies are clear and automatic
const portfolioValueAtom = atom((get) => {
const positions = get(positionsAtom)
const marketData = get(marketDataAtom)
return positions.reduce((total, position) => {
const price = marketData[position.symbol]?.price || position.avgCost
return total + (position.quantity * price)
}, 0)
})
// No useEffect needed - this just works
function PortfolioDisplay() {
const [value] = useAtom(portfolioValueAtom)
return <div>${value.toFixed(2)}</div>
}
Look at that beautiful, predictable state management. No useEffect spaghetti. No dependency array anxiety. No "I hope these state updates happen in the right order" nightmares.
Testing: Where Context Goes to Die
Oh, you want to talk about testing? cracks knuckles Let's talk about testing.
Here's how you test Context-based state:
// Testing Context: a journey through pain
test('should update portfolio value when positions change', () => {
const wrapper = ({ children }) => (
<TradingProvider>
<MarketDataProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</MarketDataProvider>
</TradingProvider>
)
const { getByTestId } = render(<PortfolioDisplay />, { wrapper })
// Now I need to somehow update the context...
// But I can't access the setters directly...
// So I need to render another component that updates them...
// This is getting ridiculous
})
Now feast your eyes on testing atoms:
// Testing atoms: like a warm hug for your test suite
import { createStore } from 'jotai'
test('should calculate portfolio value correctly', () => {
const store = createStore()
// Set up initial state
store.set(positionsAtom, [
{ symbol: 'AAPL', quantity: 10, avgCost: 150 },
{ symbol: 'GOOGL', quantity: 5, avgCost: 2800 }
])
store.set(marketDataAtom, {
'AAPL': { price: 160 },
'GOOGL': { price: 2900 }
})
// Get computed value
const portfolioValue = store.get(portfolioValueAtom)
expect(portfolioValue).toBe(16100) // (10 * 160) + (5 * 2900)
})
test('should generate risk alerts when risk is high', () => {
const store = createStore()
// Set up high-risk scenario
store.set(positionsAtom, [/* high-risk positions */])
store.set(userPreferencesAtom, { riskTolerance: 0.3 })
const riskMetrics = store.get(riskMetricsAtom)
expect(riskMetrics.riskLevel).toBe('HIGH')
const alerts = store.get(riskAlertsAtom)
expect(alerts).toHaveLength(1)
expect(alerts[0].type).toBe('risk')
})
Do you see that? Pure functions testing pure state. No mocking providers. No wrapper components. No praying to the React gods that your test setup is correct.
Real-World Win: The Multi-Asset Trading Platform
Let me tell you about the app that almost broke me before Jotai saved the day. It was a multi-asset trading platform with:
- Real-time data for stocks, crypto, forex, and commodities
- Complex order types (limit, stop, OCO, trailing stop)
- Risk management with real-time position monitoring
- Multi-account support with different permissions per account
- Advanced charting with 50+ technical indicators
- Social features with trade sharing and following
Before Jotai, this app had:
- 47 different Context providers
- 156 useEffect hooks trying to coordinate state
- Test files that took 25 minutes to run
- Bugs that appeared faster than I could fix them
- Team members who started avoiding the codebase
After Jotai:
- 89 simple atoms with clear dependencies
- Zero useEffect hooks for state coordination
- Tests that run in under 2 minutes
- Bugs that became endangered species
- Team members fighting over who gets to work on new features
Here's a taste of the trading platform atoms:
// Multi-asset market data
const stockDataAtom = atom({})
const cryptoDataAtom = atom({})
const forexDataAtom = atom({})
const commodityDataAtom = atom({})
// Unified market data view
const allMarketDataAtom = atom((get) => ({
stocks: get(stockDataAtom),
crypto: get(cryptoDataAtom),
forex: get(forexDataAtom),
commodities: get(commodityDataAtom)
}))
// Account management
const selectedAccountAtom = atom('main')
const accountsAtom = atom(['main', 'retirement', 'trading'])
const currentAccountPositionsAtom = atom((get) => {
const account = get(selectedAccountAtom)
const allPositions = get(positionsAtom)
return allPositions.filter(p => p.account === account)
})
// Order management with complex validation
const pendingOrderAtom = atom({
symbol: '',
quantity: 0,
type: 'market',
side: 'buy'
})
const orderValidationAtom = atom((get) => {
const order = get(pendingOrderAtom)
const account = get(selectedAccountAtom)
const positions = get(currentAccountPositionsAtom)
const marketData = get(allMarketDataAtom)
const userPermissions = get(userPermissionsAtom)
const errors = []
// Validate order based on account permissions
if (!userPermissions[account]?.canTrade) {
errors.push('Trading not permitted for this account')
}
// Validate position size limits
const currentPosition = positions.find(p => p.symbol === order.symbol)
const newQuantity = order.side === 'buy'
? (currentPosition?.quantity || 0) + order.quantity
: (currentPosition?.quantity || 0) - order.quantity
if (Math.abs(newQuantity) > userPermissions[account]?.maxPositionSize) {
errors.push('Position size would exceed account limits')
}
// Validate market hours for the asset type
const assetType = getAssetType(order.symbol)
if (!isMarketOpen(assetType)) {
errors.push(`${assetType} market is currently closed`)
}
return {
isValid: errors.length === 0,
errors
}
})
// Social features with automatic updates
const followedTradersAtom = atom([])
const socialFeedAtom = atom((get) => {
const followedTraders = get(followedTradersAtom)
const allTrades = get(allTradesAtom) // Imagine this comes from a websocket
return allTrades
.filter(trade => followedTraders.includes(trade.traderId))
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50)
})
// Real-time P&L across all accounts
const totalPortfolioValueAtom = atom((get) => {
const accounts = get(accountsAtom)
const allMarketData = get(allMarketDataAtom)
return accounts.reduce((total, account) => {
const accountPositions = get(positionsAtom).filter(p => p.account === account)
const accountValue = calculateAccountValue(accountPositions, allMarketData)
return total + accountValue
}, 0)
})
This atom-based architecture handles complexity that would have been impossible with Context. Multiple asset types, cross-account calculations, real-time validation, and social features all work together seamlessly.
The Performance Bonus
Here's something I didn't expect: Jotai actually made my app faster. How? Because instead of re-rendering entire Context subtrees when state changes, Jotai only updates components that use atoms that actually changed.
Before Jotai:
- Market data update triggered re-render of 47 components
- Portfolio calculations happened in multiple places
- Component tree re-renders caused UI lag during high-frequency updates
After Jotai:
- Market data update only re-renders components using market data atoms
- Portfolio calculations happen once in derived atoms
- Surgical updates keep UI smooth even during market volatility
Why Jotai Wins
Let me count the ways Jotai has improved my state management life:
1. Atomic Independence: Each piece of state is independent and composable.
2. Automatic Dependencies: Derived atoms automatically update when dependencies change.
3. No Prop Drilling: Access any atom from any component without passing props.
4. Selective Updates: Only components using changed atoms re-render.
5. Testable State: Test state logic in isolation without React components.
6. TypeScript Perfect: First-class TypeScript support with perfect inference.
7. DevTools: Excellent debugging tools to visualize atom relationships.
8. Async Support: Built-in support for async atoms and Suspense.
The Bottom Line
Before Jotai, managing complex React state was like juggling flaming chainsaws while blindfolded—technically possible, but why would you want to? After Jotai, it's like having telepathic powers. You think about state changes, and they happen exactly where they need to.
If you're still using Context for everything or prop drilling like it's 2018, please, for the love of all that is holy in the React ecosystem, give Jotai a try. Your components will be more performant, your state will be more predictable, and your sanity will remain intact.
Your users will get faster interactions. Your tests will actually test state logic. Your future self will thank you when you need to add that complex feature the product team just thought of.
But most importantly, you'll stop having nightmares about state management. And isn't that what we all really want?
Now go forth and atomize. Your state, that is. Atomize your state.