Ibrahim Efendic
1 article
July 21, 2025
Software Development
Optimizing State Management in React Using Custom Hooks
In modern React development, it’s common to start with patterns that work well for small to mid-sized components. However, as UI complexity increases—especially with features like segmented views or tabs—these patterns can quickly show their limitations in terms of performance and maintainability. One such scenario often emerges when a single parent component renders a large number of child components, each responsible for displaying its own widget or data view. If not carefully managed, this setup can lead to unnecessary re-renders, tight coupling between logic and presentation, and inefficient data fetching. This Tech Bite examines how utilizing custom React hooks for state and data management can offer a cleaner and more efficient alternative. The Initial Setup: The Standard “Props Down, Events Up” Approach A typical React structure involves a parent component orchestrating data flow and passing necessary props—such as fetch functions—down to its children. Each child component then invokes the appropriate fetch method inside a useEffect hook and manages its own loading, success, and error states locally. This structure is straightforward and works well when all components are visible on the screen simultaneously. However, challenges arise when the UI introduces conditional rendering, such as tabbed interfaces. In such cases, switching between tabs often triggers component remounts and re-fetches, leading to degraded performance, redundant network requests, and a suboptimal user experience. The Problem: Unnecessary Re-renders and Lost Performance With the implementation of the tabs mentioned above, every time the user switches between the tabs, it would cause a re-render of the widgets for that tab. There was no way to prevent the widgets from re-rendering after they were initially loaded. Now, this led to a couple of different issues: Data was being fetched over and over, needlessly It was causing a bad user experience, wasting the user's time by sitting and waiting for loading screens Difficulty in handling preloading and retry mechanisms We knew there had to be a better way to efficiently control the states. The Solution: Moving to Custom Hooks for Cleaner Data Flow That’s when we decided to rethink the structure. Instead of having each child manage its own fetching logic, we created a custom React hook that handles everything related to data fetching in a reusable manner. Here’s what it looks like: /** * Custom hook for handling data fetching for widgets * @param fetch - the function that returns a Promise with the data, added in dependency list to ensure the latest state of the function - as it can depend on outside parameters * @returns response object containing data, loading, error, and fetch method */ export function useWidgetState<T>( fetch: () => Promise<T>, ): { response: ApiResponse<T>; } { const [widgetState, setWidgetState] = useState<T | null>(null); const [isLoading, setIsLoading] = useState<boolean>(false); const [error, setError] = useState<string | null>(null); const [hasExecuted, setHasExecuted] = useState<boolean>(false); const fetchData = useCallback(async () => { setIsLoading(true); setError(null); try { const response = await fetch(); setHasExecuted(true); setWidgetState(response); } catch (error) { setError(error.message || 'Unknown error'); } finally { setIsLoading(false); } }, [fetch]); return { response: { data: widgetState, isLoading, error, fetchMethod: fetchData, hasExecuted, }, }; } This hook provides a clean and predictable way to handle data fetching. More importantly, it allowed us to control exactly when a fetch would happen—no more automatic useEffect calls in child components. Consider this example of how the hook is used through several helper methods: export const FirstComponent { const fetchFeatures = useCallback(() => { // Define method for fetching data }, [...dependencyList]) const { response: featuresResponse } = useWidgetState(fetchFeatures); // Rest of the component.. } What Changed: Centralized Execution, Smarter Rendering With this new hook in place, we changed our component structure: The Parent component now owns all the data. It uses useWidgetState multiple times - once for each child/widget that needs data. Instead of passing down fetch methods, it passes down already-fetched data alongside error and loading states. The children are now mostly dummy components. They render whatever data they receive - no more useEffect, no more useState, and no more re-fetching on tab change. Silent Preloading: A Nice Bonus Because the hook doesn’t auto-run and is controlled manually by the parent, we were able to take things a step further. After a user opens one tab, we can start preloading the data for the following tabs quietly in the background. const run = async () => { switch (tabId) { case PageTabs.FIRST_TAB: await loadFirstTabData(); break; case PageTabs.SECOND_TAB: await loadSecondTabData(); break; case PageTabs.THIRD_TAB: Await loadThirdTabData(); break; default: return; } const preloadOtherTabsSilently = async (excludeTabId: string) => { const preloadTabData = async (tabId: string) => { /* All load methods take a flag to signal that silent loading should be performed */ switch (tabId) { case PageTabs.FIRST_TAB: await loadFirstTabData(true); break; case PageTabs.SECOND_TAB: await loadSecondTabData(true); break; case PageTabs.THIRD_TAB: await loadThirdTabData(true); break; } }; const otherTabIds = Object.values(PageTabs).filter( id => id !== excludeTabId, ); for (const tabId of otherTabIds) { await preloadTabData(tabId); } }; // After main tab finishes, preload other tabs silently void preloadOtherTabsSilently(tabId); } That means by the time the user switches tabs, the data is already there—no loading spinner, no network delay, just instant rendering. This wasn’t something we could easily do with our old setup, but with this new hook-based architecture, it felt like a natural enhancement. A Visual Comparison To help visualize the difference, here’s a simple diagram: On the left is the standard approach: fetch functions passed to children, each handling its own logic. On the right is our custom hook setup: the parent owns all state and passes down fulfilled data. Why This Works There’s something satisfying about this kind of refactor. Not only did it fix the immediate problems, extra re-renders and inefficient fetch calls, it also made the code easier to read and maintain. A few other benefits we noticed: Reusability: The useWidgetState hook is generic and can be reused across multiple widgets. Better UX: Thanks to preloading, our UI feels faster and more responsive. Scalability: As we add more widgets and tabs, the structure holds up without becoming chaotic. Final Thoughts React gives you a lot of flexibility, but with that flexibility comes responsibility. It's easy to let state and side effects creep into every component, but that doesn’t always scale well. Custom hooks gave us a way to abstract, centralize, and optimize how we manage data across a dynamic UI. It might seem like a minor change in architecture, but in practice, it made a significant difference in both performance and developer satisfaction. "Optimizing State Management in React Using Custom Hooks" Tech Bite was brought to you by Ibrahim Efendić, Junior Software Engineer at Atlantbh. (more…)
Ready to Achieve More?
We’ll help you reach your goals quickly with an easy and straightforward process to kick off our collaboration. Here’s what happens next.