My Journey Through React State Management Hell and Back

react state-management redux context-api hooks

A personal tale of evolving from prop drilling to Redux to hooks and the lessons learned along the way


The Innocent Beginnings: Props All the Way Down

My React journey began in 2017. I had just landed my first serious frontend role after years of jQuery and vanilla JavaScript. React was still relatively new, and the ecosystem wasn't as mature as it is today.

My first React project was a dashboard for a financial services company. It started simple enough—a few components retrieving data from an API and displaying it in tables and charts. State management was straightforward:

class Dashboard extends React.Component {
  state = {
    userData: null,
    isLoading: true,
    error: null
  };
 
  componentDidMount() {
    fetch("/api/user-data")
      .then((res) => res.json())
      .then((data) => this.setState({ userData: data, isLoading: false }))
      .catch((error) => this.setState({ error, isLoading: false }));
  }
 
  render() {
    const { userData, isLoading, error } = this.state;
 
    if (isLoading) return <LoadingSpinner />;
    if (error) return <ErrorMessage message={error.message} />;
 
    return (
      <div>
        <UserSummary userData={userData} />
        <AccountsTable accounts={userData.accounts} />
        <TransactionHistory transactions={userData.transactions} />
      </div>
    );
  }
}

Each component received its data via props, and everything worked fine—at first. But as the project grew, so did the complexity. We added user preferences, notification settings, filtering options, and more interactive elements.

Before long, I found myself passing props through five or six levels of components. Components in the middle of the tree were receiving props they didn't use, just to pass them down further. The dreaded "prop drilling" had begun.

// This component doesn't use transactions, just passes them down
function AccountSection({ userData, transactions, onUpdateAccount }) {
  return (
    <div>
      <AccountSummary userData={userData} />
      <AccountDetails
        accountData={userData.primaryAccount}
        transactions={transactions}
        onUpdateAccount={onUpdateAccount}
      />
    </div>
  );
}
 
// Even deeper...
function AccountDetails({ accountData, transactions, onUpdateAccount }) {
  return (
    <div>
      <AccountInfo data={accountData} onUpdate={onUpdateAccount} />
      <RecentTransactions transactions={transactions} />
    </div>
  );
}

The code became harder to maintain. Adding a new feature often meant modifying multiple component props down the tree. Worse, it was difficult for new team members to understand how data flowed through the application.

I knew there had to be a better way.

The Redux Era: Global State to the Rescue?

By early 2018, our application had grown even more complex. We were managing users, accounts, transactions, notifications, UI state, form state, and more. Prop drilling had become unsustainable.

That's when I discovered Redux. It promised to solve all our state management problems with a centralized store, predictable state updates, and easy access to data from any component.

I spent a weekend learning Redux and came back to work excited to refactor our application. The team was initially skeptical—introducing a new library meant additional learning and complexity—but they agreed it was necessary.

I started by identifying the global state that multiple components needed:

// actions.js
export const FETCH_USER_DATA_BEGIN = "FETCH_USER_DATA_BEGIN";
export const FETCH_USER_DATA_SUCCESS = "FETCH_USER_DATA_SUCCESS";
export const FETCH_USER_DATA_FAILURE = "FETCH_USER_DATA_FAILURE";
 
export const fetchUserData = () => (dispatch) => {
  dispatch({ type: FETCH_USER_DATA_BEGIN });
 
  return fetch("/api/user-data")
    .then((res) => res.json())
    .then((data) => {
      dispatch({
        type: FETCH_USER_DATA_SUCCESS,
        payload: data
      });
    })
    .catch((error) => {
      dispatch({
        type: FETCH_USER_DATA_FAILURE,
        payload: error
      });
    });
};
 
// reducers.js
const initialState = {
  userData: null,
  loading: false,
  error: null
};
 
export function userReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_USER_DATA_BEGIN:
      return {
        ...state,
        loading: true,
        error: null
      };
 
    case FETCH_USER_DATA_SUCCESS:
      return {
        ...state,
        loading: false,
        userData: action.payload
      };
 
    case FETCH_USER_DATA_FAILURE:
      return {
        ...state,
        loading: false,
        error: action.payload
      };
 
    default:
      return state;
  }
}

Then, I connected our components to the Redux store:

import { connect } from "react-redux";
import { fetchUserData } from "./actions";
 
class Dashboard extends React.Component {
  componentDidMount() {
    this.props.fetchUserData();
  }
 
  render() {
    const { userData, loading, error } = this.props;
 
    if (loading) return <LoadingSpinner />;
    if (error) return <ErrorMessage message={error.message} />;
 
    return (
      <div>
        <UserSummary userData={userData} />
        <AccountsTable accounts={userData.accounts} />
        <TransactionHistory transactions={userData.transactions} />
      </div>
    );
  }
}
 
const mapStateToProps = (state) => ({
  userData: state.user.userData,
  loading: state.user.loading,
  error: state.user.error
});
 
export default connect(mapStateToProps, { fetchUserData })(Dashboard);

And now any component could access the data it needed directly from the store:

import { connect } from "react-redux";
 
function RecentTransactions({ transactions }) {
  // No more prop drilling!
  return (
    <div>
      {transactions.map((transaction) => (
        <TransactionItem key={transaction.id} transaction={transaction} />
      ))}
    </div>
  );
}
 
const mapStateToProps = (state) => ({
  transactions: state.user.userData ? state.user.userData.transactions : []
});
 
export default connect(mapStateToProps)(RecentTransactions);

At first, Redux felt like a revelation. Components could access data without prop drilling. State updates were predictable and could be tracked with Redux DevTools. We could implement complex features like undo/redo.

But as our application continued to grow, new problems emerged:

  1. Boilerplate Explosion: Every new feature required actions, action creators, reducers, and connected components. What should have been a simple feature often required changes to multiple files.

  2. Indirection Issues: It became difficult to trace where state changes were coming from. A single action might be dispatched from multiple components.

  3. Performance Concerns: Components were re-rendering unnecessarily because they were connected to too much state.

  4. Steep Learning Curve: New team members struggled with Redux concepts and patterns.

After six months, our codebase had doubled in size, and much of that was Redux boilerplate. The productivity gains we'd hoped for hadn't materialized. Instead, we were spending more time maintaining our state management code than building new features.

The Context API Experiment

In early 2019, React's Context API caught my attention. It promised a simpler alternative to Redux for many use cases, especially for state that needed to be shared across components but didn't need the full Redux treatment.

I identified a feature that was causing particular pain with Redux: user preferences. These settings affected multiple components but had simple update patterns. It seemed like a perfect candidate for Context.

// UserPreferencesContext.js
import React, { createContext, useState, useEffect } from "react";
 
export const UserPreferencesContext = createContext();
 
export function UserPreferencesProvider({ children }) {
  const [preferences, setPreferences] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    fetch("/api/user-preferences")
      .then((res) => res.json())
      .then((data) => {
        setPreferences(data);
        setLoading(false);
      })
      .catch((error) => {
        console.error("Failed to load preferences:", error);
        setLoading(false);
      });
  }, []);
 
  const updatePreference = (key, value) => {
    if (!preferences) return;
 
    const newPreferences = {
      ...preferences,
      [key]: value
    };
 
    setPreferences(newPreferences);
 
    // Persist to backend
    fetch("/api/user-preferences", {
      method: "PUT",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(newPreferences)
    }).catch((error) => {
      console.error("Failed to save preferences:", error);
    });
  };
 
  return (
    <UserPreferencesContext.Provider
      value={{
        preferences,
        loading,
        updatePreference
      }}
    >
      {children}
    </UserPreferencesContext.Provider>
  );
}

Using the Context in components was straightforward:

import React, { useContext } from "react";
import { UserPreferencesContext } from "./UserPreferencesContext";
 
function DarkModeToggle() {
  const { preferences, updatePreference } = useContext(UserPreferencesContext);
 
  if (!preferences) return null;
 
  return (
    <label className="toggle">
      <input
        type="checkbox"
        checked={preferences.darkMode}
        onChange={(e) => updatePreference("darkMode", e.target.checked)}
      />
      Dark Mode
    </label>
  );
}

The Context API experiment was a success for simpler state requirements. It reduced boilerplate and made the code more straightforward. But it wasn't a complete replacement for Redux, especially for complex state interactions and middleware requirements.

We ended up with a hybrid approach: Redux for global application state and complex interactions, Context for simpler shared state like themes and user preferences.

The Hooks Revolution

In late 2019, React Hooks changed everything. useState, useEffect, useReducer, and custom hooks opened up new possibilities for state management that were both powerful and elegant.

I was particularly excited about useReducer, which offered Redux-like state management without the external library. For local component state with complex logic, it was perfect:

import React, { useReducer, useEffect } from "react";
 
const initialState = {
  data: null,
  loading: true,
  error: null
};
 
function reducer(state, action) {
  switch (action.type) {
    case "FETCH_START":
      return { ...state, loading: true, error: null };
    case "FETCH_SUCCESS":
      return { ...state, loading: false, data: action.payload };
    case "FETCH_ERROR":
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}
 
function DataFetcher({ url, renderData }) {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  useEffect(() => {
    dispatch({ type: "FETCH_START" });
 
    fetch(url)
      .then((res) => res.json())
      .then((data) => dispatch({ type: "FETCH_SUCCESS", payload: data }))
      .catch((error) =>
        dispatch({ type: "FETCH_ERROR", payload: error.message })
      );
  }, [url]);
 
  const { data, loading, error } = state;
 
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
 
  return renderData(data);
}

But the real game-changer was custom hooks. They allowed us to extract and reuse stateful logic across components. For instance, I created a useFetch hook that encapsulated data fetching logic:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    setLoading(true);
    setError(null);
 
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((error) => {
        setError(error.message);
        setLoading(false);
      });
  }, [url]);
 
  return { data, loading, error };
}

Using this hook simplified our components dramatically:

function UserProfile({ userId }) {
  const { data, loading, error } = useFetch(`/api/users/${userId}`);
 
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
 
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      {/* More profile information */}
    </div>
  );
}

With hooks, we were able to gradually reduce our reliance on Redux. For many features, a combination of useState, useReducer, useContext, and custom hooks provided all the state management we needed with less boilerplate and complexity.

Finding Balance: The Modern Approach

By 2021, our approach to state management had evolved considerably. We had moved from a "Redux for everything" mindset to a more nuanced approach:

  1. Component State: For state that only affects a single component, useState or useReducer.

  2. Shared State: For state shared across a subtree of components, useContext combined with useState or useReducer.

  3. Global Application State: For complex global state, we still used Redux, but with a more focused approach—only truly global state went into Redux.

  4. Server Cache State: For data fetched from APIs, we adopted React Query, which handled caching, re-fetching, and stale data elegantly.

Here's an example of how we might structure a feature today:

// UserProfilePage.js
import React from "react";
import { useQuery } from "react-query";
import { UserPreferencesProvider } from "./UserPreferencesContext";
import Profile from "./Profile";
import ActivityFeed from "./ActivityFeed";
 
function UserProfilePage({ userId }) {
  const {
    data: user,
    isLoading,
    error
  } = useQuery(["user", userId], () =>
    fetch(`/api/users/${userId}`).then((res) => res.json())
  );
 
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error.message} />;
 
  return (
    <UserPreferencesProvider>
      <div className="profile-page">
        <Profile user={user} />
        <ActivityFeed userId={userId} />
      </div>
    </UserPreferencesProvider>
  );
}
 
// Profile.js
import React, { useState } from "react";
 
function Profile({ user }) {
  const [isEditing, setIsEditing] = useState(false);
 
  // Component state for the edit form
  const [formData, setFormData] = useState({
    name: user.name,
    bio: user.bio
  });
 
  // Local component functions
  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
  };
 
  const handleSubmit = (e) => {
    e.preventDefault();
    // Submit changes...
    setIsEditing(false);
  };
 
  return <div className="profile">{/* Profile UI */}</div>;
}
 
// UserPreferencesContext.js (as shown earlier)
// ...
 
// In a component that needs user preferences
function ThemeToggle() {
  const { preferences, updatePreference } = useContext(UserPreferencesContext);
 
  return (
    <button
      onClick={() =>
        updatePreference(
          "theme",
          preferences.theme === "light" ? "dark" : "light"
        )
      }
    >
      Toggle Theme
    </button>
  );
}

This approach gives us the best of all worlds:

Lessons Learned

Looking back on my journey through React state management, I've learned several important lessons:

  1. One size doesn't fit all: Different types of state have different requirements, and it's okay to use different strategies for them.

  2. Avoid premature optimization: Start simple and add complexity only when you need it. Many applications don't need Redux or other complex state management libraries.

  3. Consider state locality: Keep state as close as possible to where it's used. Global state should be truly global.

  4. Separate UI state from data: UI state (like whether a dropdown is open) and data (like user information) have different patterns and often benefit from different management approaches.

  5. Embrace evolution: The best approach today might not be the best approach tomorrow. Be open to evolving your state management strategy as your application and the React ecosystem evolve.

Perhaps the most important lesson is that there's no silver bullet for state management. Every approach has tradeoffs, and the best solution depends on your specific requirements, team expertise, and application complexity.

Today, our applications use a mix of hooks, Context, and sometimes external libraries like Redux Toolkit, Zustand, or Jotai. We're pragmatic rather than dogmatic, choosing the right tool for each specific state management need.

The React ecosystem continues to evolve, and I'm excited to see what new state management approaches emerge in the future. But whatever comes next, I'm confident that the principles I've learned—simplicity, locality, and separation of concerns—will remain relevant.

So if you're struggling with state management in your React application, take heart. The path to state management enlightenment is rarely straight, but the journey is worth it. Start simple, learn from your mistakes, and don't be afraid to refactor as your understanding grows. Your future self—and your team—will thank you.