Your Components Are Crying: A Sarcastic Intervention for useState Addicts

Listen. We need to have a talk. I know you think you're hot stuff with your useState calls scattered throughout your component like confetti at a particularly chaotic New Year's party, but I'm here to tell you that your components are literally crying. I can hear them weeping through my monitor as I review your pull requests.

It's time for an intervention.

The Crime Scene: A useState Massacre

Let me paint you a picture of the horror I witnessed just yesterday. Picture, if you will, a shopping cart component that started innocent enough:

function ShoppingCart() {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  const [isOpen, setIsOpen] = useState(false)
  const [discount, setDiscount] = useState(0)
  const [shippingCost, setShippingCost] = useState(0)
  const [promoCode, setPromoCode] = useState('')
  const [promoError, setPromoError] = useState('')
  const [userPoints, setUserPoints] = useState(0)
  const [isApplyingPromo, setIsApplyingPromo] = useState(false)
  
  // ... 47 more useState calls because apparently we're collecting them
  
  const handleAddItem = async (item) => {
    setLoading(true)
    setError(null)
    try {
      // Oh boy, here we go updating 15 different state variables
      setItems(prev => [...prev, item])
      setUserPoints(prev => prev + item.points)
      if (items.length === 0) setShippingCost(0) // Free shipping!
      // Wait, items hasn't updated yet because state is async
      // *internal screaming*
    } catch (err) {
      setError(err.message)
      setLoading(false)
      // Forgot to reset other state, cart is now in quantum superposition
    }
    setLoading(false)
  }
  
  // This function is longer than my last relationship
  // and twice as complicated
}

I'm not crying, you're crying. Actually, no—your component is crying. And it has every right to be.

Enter useReducer: The Hero We Deserve

Now, let me show you what this looks like when written by someone who clearly has their life together:

const cartReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        userPoints: state.userPoints + action.payload.points,
        shippingCost: state.items.length === 0 ? 0 : state.shippingCost
      }
    
    case 'SET_LOADING':
      return { ...state, loading: action.payload, error: null }
    
    case 'SET_ERROR':
      return { ...state, loading: false, error: action.payload }
    
    case 'APPLY_PROMO_START':
      return { ...state, isApplyingPromo: true, promoError: '' }
    
    case 'APPLY_PROMO_SUCCESS':
      return {
        ...state,
        isApplyingPromo: false,
        discount: action.payload.discount,
        promoCode: action.payload.code
      }
    
    case 'APPLY_PROMO_ERROR':
      return {
        ...state,
        isApplyingPromo: false,
        promoError: action.payload
      }
    
    default:
      return state
  }
}

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    loading: false,
    error: null,
    isOpen: false,
    discount: 0,
    shippingCost: 0,
    promoCode: '',
    promoError: '',
    userPoints: 0,
    isApplyingPromo: false
  })
  
  const handleAddItem = async (item) => {
    dispatch({ type: 'SET_LOADING', payload: true })
    try {
      dispatch({ type: 'ADD_ITEM', payload: item })
      // Look ma, no state synchronization nightmares!
    } catch (err) {
      dispatch({ type: 'SET_ERROR', payload: err.message })
    }
  }
  
  // Suddenly your event handlers are readable again
  // What sorcery is this?
}

Notice how I'm not having an existential crisis about whether items has updated yet? Notice how each action is atomic and predictable? Notice how I can actually read this code without needing a PhD in React State Archaeology?

"But Cary," You Whine, "useState is Simpler!"

Oh, sweet summer child. You think useState is simpler? Let me ask you this: when was the last time you confidently modified a component with 8+ useState calls without breaking something? I'll wait.

Here's what "simple" useState looks like in the real world:

// The "simple" useState version
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

const updateUser = async (newData) => {
  setLoading(true)
  setError(null) // Don't forget this or you'll show stale errors!
  
  try {
    const updatedUser = await api.updateUser(newData)
    setUser(updatedUser)
    setLoading(false) // Wait, what if this runs before setUser?
    // *nervous sweating*
  } catch (err) {
    setError(err.message)
    setLoading(false)
    // Did I remember to clear the user if this was a critical failure?
    // *more nervous sweating*
  }
}

Versus the reducer version that doesn't make me question my life choices:

const userReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null }
    
    case 'FETCH_SUCCESS':
      return { user: action.payload, loading: false, error: null }
    
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload }
    
    default:
      return state
  }
}

const [state, dispatch] = useReducer(userReducer, {
  user: null,
  loading: false,
  error: null
})

const updateUser = async (newData) => {
  dispatch({ type: 'FETCH_START' })
  
  try {
    const updatedUser = await api.updateUser(newData)
    dispatch({ type: 'FETCH_SUCCESS', payload: updatedUser })
  } catch (err) {
    dispatch({ type: 'FETCH_ERROR', payload: err.message })
  }
}

Look at that beautiful, predictable state management. Each action produces a known result. There's no "I hope these setState calls happen in the right order" anxiety. It's like the difference between juggling flaming torches while blindfolded versus just... not doing that.

Testing: Where useState Goes to Die

Oh, you want to talk about testing? cracks knuckles Let's talk about testing.

Here's how you test useState spaghetti:

// Testing useState components: a journey through pain
test('should update user and show loading state', async () => {
  const { getByTestId, rerender } = render(<UserComponent />)
  
  // First, let's see if loading starts
  fireEvent.click(getByTestId('update-button'))
  expect(getByTestId('loading')).toBeInTheDocument()
  
  // Now we wait... and pray... that all the state updates happened
  await waitFor(() => {
    expect(getByTestId('user-name')).toHaveTextContent('Updated Name')
  })
  
  // But wait, is loading false now? Who knows!
  // We have to check multiple things and hope they're consistent
  expect(queryByTestId('loading')).not.toBeInTheDocument()
  expect(queryByTestId('error')).not.toBeInTheDocument()
  
  // This test is longer than a CVS receipt and half as useful
})

Now feast your eyes on testing a reducer:

// Testing reducers: like a warm hug for your test suite
describe('userReducer', () => {
  test('should handle FETCH_START', () => {
    const initialState = { user: null, loading: false, error: null }
    const action = { type: 'FETCH_START' }
    
    const result = userReducer(initialState, action)
    
    expect(result).toEqual({
      user: null,
      loading: true,
      error: null
    })
  })
  
  test('should handle FETCH_SUCCESS', () => {
    const initialState = { user: null, loading: true, error: null }
    const user = { id: 1, name: 'Test User' }
    const action = { type: 'FETCH_SUCCESS', payload: user }
    
    const result = userReducer(initialState, action)
    
    expect(result).toEqual({
      user,
      loading: false,
      error: null
    })
  })
  
  // Look ma, pure functions! No mocking, no waiting, no crying!
})

Do you see that? That's the sound of your test suite not hating you. Pure functions are testable. Predictable state transitions are testable. Your random collection of useState calls... not so much.

The "Complex" State Myth

"But Cary," you might say, "I only have two pieces of state. Surely I don't need a reducer for that?"

sigh

Sure, Jan. You only have two pieces of state. Let me ask you this: when was the last time state stayed simple? Here's what happens:

Week 1: "I just need loading and data"

const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)

Week 2: "Oh, I need error handling"

const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

Week 3: "Users want to retry failed requests"

const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)

Week 4: "We need optimistic updates"

const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
const [optimisticData, setOptimisticData] = useState(null)
const [isOptimistic, setIsOptimistic] = useState(false)

Week 5: Your component is now Frankenstein's monster and you're questioning your career choices.

Meanwhile, the reducer version just... grows gracefully:

// Week 1
const initialState = { data: null, loading: false }

// Week 5
const initialState = { 
  data: null, 
  loading: false, 
  error: null, 
  retryCount: 0, 
  optimisticData: null, 
  isOptimistic: false 
}

// The reducer just gets new cases. No refactoring, no tears.

Real Talk: When useState Makes Sense

Look, I'm not a monster. There are times when useState is perfectly fine:

  • Simple toggles: const [isOpen, setIsOpen] = useState(false) for a modal? Fine.
  • Form inputs: const [email, setEmail] = useState('') for a single input? Go for it.
  • Truly independent state: If your state variables have absolutely nothing to do with each other, useState away.

But the moment—and I mean the moment—you have related state that updates together, or complex state transitions, or you find yourself with more than 3-4 useState calls, it's time to graduate to useReducer.

The Reducer Pattern: It's Not Just for React

Here's a secret: the reducer pattern isn't some React invention. It's a fundamental pattern in computer science. Redux uses it. The Elm architecture uses it. State machines use it. It's battle-tested, predictable, and makes your code actually understandable.

When you use useReducer, you're not just making your React code better—you're learning a pattern that will serve you well across languages and frameworks. When you use useState for everything, you're... well, you're making your React code worse.

Action Items (Pun Intended)

If I've convinced you to stop abusing useState like it owes you money, here's your action plan:

  1. Audit your components: Find any component with more than 3 useState calls
  2. Identify related state: Group state that changes together
  3. Start small: Pick one component and refactor it to use useReducer
  4. Write tests: Enjoy how easy it is to test pure reducer functions
  5. Spread the gospel: Teach your teammates the joy of predictable state

The Bottom Line

Your components deserve better than useState spaghetti. Your tests deserve better than flaky state assertions. Your future self deserves better than debugging state synchronization bugs at 2 AM.

useReducer isn't just a "more advanced" hook—it's the right tool for managing any non-trivial state. It makes your code more predictable, your tests more reliable, and your components more maintainable.

So please, for the love of all that is holy in the React ecosystem, stop torturing your components with useState when they clearly need the structure and predictability of a reducer.

Your components will thank you. Your tests will thank you. Your teammates will thank you.

And most importantly, I'll stop having nightmares about your pull requests.

Now go forth and reduce. Your state, that is. Reduce your state.