Unlocking React’s Hidden Gem: A Comprehensive Guide to Elegant State Management

Amit Kumar
Frontend2Backend
Published in
4 min readFeb 18, 2024
Photo by Artem Sapegin on Unsplash

The most challenging part of React is state management. It’s easy to manage states in small applications. As the codebase increases, the complexity that comes with the state handling increases as well.

This article answers the below question related to state management in React:

How to handle complex states in React beautifully?

Let’s start with having fun playing around with state management in React.

How to handle complex states in React beautifully?

Imagine that you need to create a component to search for users. The user list is returned in a paginated format. There should be a load more button at the end of the list. The load more button allows you to fetch the next page of users. Each fetch response returns a list of users along with pagination information.

The component needs a state for:

  • List of users to render.
  • Current search query.
  • A total number of pages available for the current query.
  • Page number to be fetched.
  • Current UI state such as: ‘idle’, ‘error’, or ‘empty’.

You could use useState hook to manage all these states in separate calls. The problem is that useState doesn’t work well with complex state handling. It creates an issue when one of your state variables relies on the value of another state to update. When you update one state variable, other dependent states may need to be updated.

Handling complex states using useState hook (too complex to manage):

type Mode = 'idle' | 'loading' | 'empty' | 'error';

const UserSearch = () => {
const [mode, setMode] = useState<Mode>('idle');
const [users, setUsers] = useState<UserResult>([]);
const [query, setQuery] = useState('');
const [queryPage, setQueryPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);

const fetchUsers = async () => {
try {
setMode('loading');
if (!api) {
throw new Error(`Unable to connect to API`);
}
const { results, total } = await api.fetchUsers({ query, page: queryPage });
if (!results.length) {
setMode('no-results');
} else {
setMode('idle');
}
setUsers([...users, ...results]);
setTotalPages(total);
} catch (e) {
setMode('error');
}
};

useEffect(() => {
fetchUsers();
}, [query, queryPage]);

const handleQueryChange = debounce((e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
setUsers([]);
setTotalPages(1);
}, 300);

const loadMoreUsers = () => {
const nextPage = queryPage + 1;
if (nextPage >= totalPages) return;
setQueryPage(nextPage);
};

const canLoadMoreUsers = queryPage < totalPages;

return (
<div>
<label>
Enter a search term:
<input type="search" onChange={handleQueryChange} />
</label>
{mode === 'loading' && <p>Loading...</p>}
{mode === 'no-results' && <p>No results found</p>}
{mode === 'error' && <p>Something went wrong</p>}
{mode === 'idle' && (
<ol>
{users.map((user) => {
return (
<li key={user.id}>
<div>{user.name}</div>
</li>
);
})}
</ol>
)}
{canLoadMoreUsers && (
<button type="button" onClick={loadMoreUsers}>
Load more
</button>
)}
</div>
);
};

When one of your state relies on the value of another state in order to update use useReducer hook. The useReducer hook can solve issues with complex state handling. It allows us to manage state in an elegant way.

With useReducer you don’t need to spread a component’s state across multiple variables. It allows you store them all in a single object.

useReducer can take up to three arguments:

  1. reducer: The reducer function.
  2. initialState: The initial state of your component.
  3. initializer: An optional function that can be used to populate the initial state.

The useReducer hook returns an array containing two elements. The first element is the state object. The second element is a function that can be used to dispatch an action.

Here’s an example of handling complex states beautifully:

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'set-mode': {
return { ...state, mode: action.payload };
}
case 'set-users': {
const { users, totalPages } = action.payload;
const newUsers = [...state.users, ...users];
const mode = !newUsers.length ? 'no-results' : 'idle';
return { ...state, users: newUsers, totalPages, mode };
}
case 'set-query': {
return { ...initialState, query: action.payload };
}
case 'fetch-next-page': {
const nextPage = state.queryPage + 1;
if (nextPage >= state.totalPages) return state;
return { ...state, queryPage: nextPage };
}
case 'reset': {
return initialState;
}
default:
return initialState;
}
};

const UserSearch = () => {
const initialState = {
mode: 'idle',
users: [],
query: '',
queryPage: 1,
totalPages: 1,
};

const [state, dispatch] = useReducer(reducer, initialState);

// Derived variable, no need to store in state.
const canLoadMoreUsers = state.queryPage < state.totalPages;

const fetchUsers = async () => {
try {
dispatch({ type: 'set-mode', payload: 'loading' });
if (!api) {
throw new Error(`Unable to connect to API`);
}
const response = await api.fetchUsers({ query, page: queryPage });
const { results: users, total: totalPages } = response;
dispatch({ type: 'set-users', payload: { users, totalPages }});
} catch (e) {
dispatch({ type: 'set-mode', payload: 'error' });
}
};

useEffect(() => {
fetchUsers();
}, [query, queryPage]);

const handleQueryChange = debounce((e: ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'set-query', payload: e.target.value });
}, 300);

const loadMoreUsers = () => {
dispatch({ type: 'fetch-next-page' });
};

return (
<div>
<label>
Enter a search term:
<input type="search" onChange={handleQueryChange} />
<button type="button" onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</label>
{state.mode === 'loading' && <p>Loading...</p>}
{state.mode === 'no-results' && <p>No results found</p>}
{state.mode === 'error' && <p>Something went wrong</p>}
{state.mode === 'idle' && (
<ol>
{state.users.map((user) => {
return (
<li key={user.id}>
<div>{user.name}</div>
</li>
);
})}
</ol>
)}
{canLoadMoreUsers && (
<button type="button" onClick={() => dispatch({ type: 'fetch-next-page' })}>
Load more
</button>
)}
</div>
);
};

Conclusion

It is very important that state management is handled without hurting readability and performance. useState hook is great for most cases of state handling in React. Consider using useReducer hook when states are complex and dependent on each other.

--

--

Amit Kumar
Frontend2Backend

Amit Kumar is a frontend developer who love designing web applications with clean, consistent User Interface.