DEV Community

Jaji
Jaji

Posted on • Edited on

Optimizing React Re-renders with Custom Hooks and Memoization

React's virtual DOM and rendering system are powerful tools for building dynamic user interfaces. However, unnecessary re-renders can impact your application's performance. In this guide, we'll explore practical strategies to optimize React components using custom hooks and memoization techniques.

Understanding React Re-renders

Before diving into optimization techniques, it's crucial to understand what triggers a re-render in React:

  1. State changes within the component
  2. Props changes from the parent component
  3. Context updates affecting the component
  4. Parent component re-renders (even without prop changes)

Custom Hooks for Performance Optimization

1. useDeepComparison Hook

One common performance issue occurs when dealing with complex objects or arrays as dependencies:

const useDeepComparison = (value) => {
  const ref = useRef();

  if (!isEqual(value, ref.current)) {
    ref.current = value;
  }

  return ref.current;
};

// Usage Example
const MyComponent = ({ complexData }) => {
  const memoizedData = useDeepComparison(complexData);

  useEffect(() => {
    // This effect will only run when complexData actually changes
    performExpensiveOperation(memoizedData);
  }, [memoizedData]);
};
Enter fullscreen mode Exit fullscreen mode

2. useThrottledCallback Hook

For handling frequent updates like scroll events or real-time data:

const useThrottledCallback = (callback, delay) => {
  const [ready, setReady] = useState(true);
  const timeoutRef = useRef();

  return useCallback((...args) => {
    if (!ready) return;

    callback(...args);
    setReady(false);

    timeoutRef.current = setTimeout(() => {
      setReady(true);
    }, delay);
  }, [callback, delay, ready]);
};

// Usage Example
const ScrollComponent = () => {
  const handleScroll = useThrottledCallback(() => {
    // Handle scroll event
  }, 200);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);
};
Enter fullscreen mode Exit fullscreen mode

Effective Memoization Strategies

1. Value Memoization with useMemo

The useMemo hook is essential for memoizing expensive computations or preventing unnecessary recreations of values:

const ExpensiveCalculationComponent = ({ data }) => {
  // Memoize expensive calculation
  const processedData = useMemo(() => {
    return data.map(item => {
      // Expensive processing...
      return complexCalculation(item);
    });
  }, [data]); // Only recompute when data changes

  // Memoize object to prevent unnecessary re-renders
  const styles = useMemo(() => ({
    backgroundColor: theme.primary,
    padding: spacing.medium,
    // Complex styles...
  }), [theme.primary, spacing.medium]);

  return (
    <div style={styles}>
      {processedData.map(item => (
        <DataItem key={item.id} data={item} />
      ))}
    </div>
  );
};

// Common useMemo use cases:
const TableComponent = ({ rows, columns, filters }) => {
  // Memoize filtered data
  const filteredRows = useMemo(() => {
    return rows.filter(row => 
      filters.every(filter => filter(row))
    );
  }, [rows, filters]);

  // Memoize sorted data based on filtered results
  const sortedData = useMemo(() => {
    return [...filteredRows].sort((a, b) => 
      sortingFunction(a, b)
    );
  }, [filteredRows]);

  // Memoize aggregations
  const totals = useMemo(() => {
    return columns.reduce((acc, column) => ({
      ...acc,
      [column.key]: calculateColumnTotal(sortedData, column)
    }), {});
  }, [columns, sortedData]);

  return (
    <Table
      data={sortedData}
      totals={totals}
      columns={columns}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

When to use useMemo:

  1. Expensive calculations that don't need to be recomputed on every render
  2. Reference equality dependencies in other hooks
  3. Preventing recreation of complex objects used in child component props
  4. Optimizing context value creation

2. Component Memoization with React.memo

const ExpensiveComponent = React.memo(({ data }) => {
  // Expensive rendering logic
  return (
    <div>
      {data.map(item => (
        <ComplexItem key={item.id} {...item} />
      ))}
    </div>
  );
}, (prevProps, nextProps) => {
  // Custom comparison function
  return isEqual(prevProps.data, nextProps.data);
});
Enter fullscreen mode Exit fullscreen mode

2. Callback Memoization Patterns

const ParentComponent = () => {
  const [items, setItems] = useState([]);

  const handleItemUpdate = useCallback((id, newValue) => {
    setItems(prevItems => 
      prevItems.map(item => 
        item.id === id ? { ...item, value: newValue } : item
      )
    );
  }, []); // Empty dependency array since we use functional updates

  return (
    <div>
      {items.map(item => (
        <MemoizedChild 
          key={item.id}
          item={item}
          onUpdate={handleItemUpdate}
        />
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and Solutions

1. Inline Object Creation

❌ Problematic:

const Component = () => (
  <ChildComponent
    style={{ margin: 20, padding: 10 }}
    config={{ timeout: 500 }}
  />
);
Enter fullscreen mode Exit fullscreen mode

✅ Optimized:

const Component = () => {
  const style = useMemo(() => ({ margin: 20, padding: 10 }), []);
  const config = useMemo(() => ({ timeout: 500 }), []);

  return <ChildComponent style={style} config={config} />;
};
Enter fullscreen mode Exit fullscreen mode

2. Context Optimization

Instead of providing a large context object, split it into smaller, more focused contexts:

const UserContext = createContext();
const ThemeContext = createContext();
const SettingsContext = createContext();

const AppProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [settings, setSettings] = useState({});

  const userValue = useMemo(() => ({ user, setUser }), [user]);
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
  const settingsValue = useMemo(() => ({ settings, setSettings }), [settings]);

  return (
    <UserContext.Provider value={userValue}>
      <ThemeContext.Provider value={themeValue}>
        <SettingsContext.Provider value={settingsValue}>
          {children}
        </SettingsContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring

To identify unnecessary re-renders and verify optimization effectiveness:

  1. Use React Developer Tools' Profiler
  2. Implement the following debug hook:
const useRenderTracking = (componentName) => {
  const renderCount = useRef(0);

  useEffect(() => {
    renderCount.current += 1;
    console.log(`${componentName} rendered ${renderCount.current} times`);
  });
};

// Usage
const MyComponent = () => {
  useRenderTracking('MyComponent');
  // ... component logic
};
Enter fullscreen mode Exit fullscreen mode

Best Practices Summary

  1. Use React.memo() for components that receive the same props frequently but don't need to re-render
  2. Implement custom hooks for complex comparisons and specialized behaviors
  3. Memoize callbacks with useCallback when passed as props to memoized child components
  4. Split context providers to minimize unnecessary re-renders
  5. Use profiling tools to identify performance bottlenecks
  6. Consider the cost of memoization itself - don't over-optimize

Conclusion

Optimizing React re-renders is a balance between performance and code complexity. Start with the simplest solution and optimize based on actual performance measurements. Remember that premature optimization can lead to harder-to-maintain code without significant performance benefits.

By implementing these patterns thoughtfully and measuring their impact, you can create React applications that maintain excellent performance even as they scale in complexity.

Top comments (0)