avatarMate Marschalko

Summary

The provided content outlines 18 advanced React techniques that senior developers can use to address complex challenges in React applications, such as performance optimization, state management, and data fetching.

Abstract

As React applications scale in complexity, developers often encounter challenges that require more sophisticated solutions beyond basic patterns. The article introduces 18 advanced React techniques tailored for senior developers to tackle issues like performance bottlenecks, state management complexity, and data fetching intricacies. These techniques range from leveraging useCallback for stable service references to using Suspense for data fetching with a global resource cache. The article also covers error handling with automatic retries, virtualizing lists with dynamic item heights, employing state machines for complex UI flows, and managing concurrency with useTransition. Additionally, it discusses the use of useImperativeHandle for controlled component APIs, progressive hydration for server-side rendering, combining portals with Suspense for heavy modals, and utilizing the React Profiler API for dynamic performance adjustments. The article emphasizes the importance of immutability with useReducer and Immer, efficient rendering of large SVG/Canvas elements, and the strategic use of useLayoutEffect for layout measurements. These techniques are presented to help developers maintain high performance and clean codebases as their applications and expertise grow.

Opinions

  • The author suggests that as developers progress from junior to senior roles, they need to adopt more advanced React techniques to handle the increasing complexity of their applications.
  • The article posits that using a ref instead of state can be simpler and more efficient for values that do not trigger re-renders.
  • It is implied that Suspense can greatly simplify data fetching by centralizing loading logic and providing a cleaner approach to rendering fallback UI.
  • The author conveys that code splitting with dynamic imports and preload hints can improve performance by loading components in the background before they are needed.
  • The concept of error boundaries that retry automatically is presented as a user experience improvement, turning temporary glitches into seamless recoveries.
  • The article expresses that virtualizing lists with dynamic item heights is crucial for maintaining performance and smooth scrolling in applications with large lists.
  • The use of a state machine is recommended for managing complex UI flows, offering a clear and debuggable representation of the application's state transitions.
  • The author emphasizes the importance of controlled concurrency with useTransition to keep the UI responsive under heavy load conditions.
  • It is suggested that useImperativeHandle can be used to create neat, controlled component APIs, which can be particularly useful for parent-child component interactions.
  • The technique of progressive hydration is advocated for server-side rendering to ensure that initial loads are fast and interactivity is not hindered by heavy components.
  • The article recommends using useLayoutEffect for smooth layout measurements when immediate adjustments are necessary before the browser paint.
  • The author encourages the use of the React Profiler API to measure and optimize render times in production, allowing for dynamic performance tweaks.
  • The combination of useReducer with Immer is presented as a solution for managing deeply nested state with ease, ensuring immutability without the complexity of manual cloning.
  • The article advises on rendering large SVG/Canvas elements efficiently by using windowing and portals to break down graphics into manageable pieces.

18 Advanced React Techniques Every Senior Dev Needs to Know

As React applications grow more complex, the patterns that were “just fine” when you were starting out might start to feel limiting. Maybe you’ve built a successful MVP, but now you’re noticing subtle performance issues. Or perhaps your state management has gotten tangled, and your data fetching logic has mushroomed into something unrecognisable.

This happens to everyone as they move from junior to more intermediate or senior React work. The good news is that there are some advanced techniques that can help you simplify complex problems. In this article, we’ll walk through 18 such techniques, ranging from clever use of useCallback and ref, to harnessing Suspense for data fetching, playing with virtualisation, improving error handling, optimising performance, and more.

A developer applying advanced react techniques (Photo by Kemal Esensoy on Unsplash)

These might sound intimidating at first, but I’ll keep things approachable. By the end, you’ll have a richer toolkit to draw on when your codebase (and your career!) starts hitting those more complex challenges.

1. Use useCallback with a Persistent Service Reference

We often see useCallback used to memoize inline arrow functions in event handlers. We do this to make sure that the function reference remains stable and does not trigger unnecessary re-renders when passed as a prop.

But as you level up, you’ll find you can use it to maintain stable references to more complex services—like WebSockets, workers, or other persistent resources—so they aren’t re-created unnecessarily on each render.

This approach builds on the basics of useRef and useCallback: you’re ensuring a long-lived service connection remains stable. This can save performance overhead and avoid unintended reconnections.

Example:

function createExpensiveService() {
  // Pretend this sets up a WebSocket or a shared worker
  return { send: (msg) => console.log('Sending:', msg) };
}

function usePersistentService() {
  const serviceRef = React.useRef(createExpensiveService());
  // Memoize the send function so it never changes
  const stableSend = React.useCallback((msg) => {
    serviceRef.current.send(msg);
  }, []);
  return stableSend;
}
function MyComponent() {
  const send = usePersistentService();
  return <button onClick={() => send('HELLO')}>Send Message</button>;
}

2. Using a Ref for Simplicity Instead of State

Sometimes we make the mistake to put all changing data in a state. But sometimes, you just need a value that won’t trigger a re-render. In these cases, using a ref is actually simpler and more efficient.

For example, imagine a counter that you just need to read and update internally without affecting the UI. A ref is perfect. No need for useState or fancy renders—just a stable box to store a changing value.

Example:

function MyCounter() {
  const counterRef = React.useRef(0);
  const increment = () => {
    counterRef.current++;
    console.log('Ref count is now:', counterRef.current);
  };
  return <button onClick={increment}>Increment (Check Console)</button>;
}

3. Using Suspense for Data Fetching with a Global Resource Cache

In many React apps, fetching data involves useEffect, loading states, and a bunch of manual checks. Suspense can simplify all of this by allowing components to “read” from a special data resource. If the data isn’t ready, the component automatically suspends, and React shows a fallback UI until the data arrives. This approach centralises loading logic, making your components cleaner and more focused on rendering.

Example (Conceptual):

function createResource(fetchFn) {
  let status = 'pending';
  let result;
  const promise = fetchFn().then(
    data => { status = 'success'; result = data; },
    err => { status = 'error'; result = err; }
  );
  return {
    read() {
      if (status === 'pending') throw promise;
      if (status === 'error') throw result;
      return result;
    }
  };
}

const userResource = createResource(() => fetch('/api/user').then(r => r.json()));

function UserProfile() {
  const user = userResource.read();
  return <div>Hello, {user.name}!</div>;
}

function App() {
  return (
    <React.Suspense fallback={<div>Loading user data...</div>}>
      <UserProfile />
    </React.Suspense>
  );
}

4. Suspense with Dynamic Imports and Preload Hints

Code splitting is common with React.lazy(), but you can take it further by preloading code before the user needs it. This reduces the wait time when they finally click a button or navigate to a certain route. Start loading your heavy components in the background so that they are instantly ready when needed.

Example:

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function usePreloadHeavyChart() {
  React.useEffect(() => {
    import('./HeavyChart'); // start preloading on mount
  }, []);
}

function Dashboard() {
  usePreloadHeavyChart();
  return (
    <React.Suspense fallback={<div>Loading Chart...</div>}>
      <HeavyChart />
    </React.Suspense>
  );
}

5. Error Boundaries that Retry Automatically

At some point, something in your app may fail — maybe a network request or a lazy-loaded component. Traditional error boundaries show a fallback UI and stop there. By enhancing them, you can try recovering automatically, retrying after a short delay. This can be a great user experience improvement, turning a temporary glitch into a seamless recovery.

Example:

class AutoRetryErrorBoundary extends React.Component {
  state = { hasError: false, attempt: 0 };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidUpdate() {
    if (this.state.hasError) {
      setTimeout(() => {
        this.setState(s => ({ hasError: false, attempt: s.attempt + 1 }));
      }, 2000);
    }
  }
  render() {
    if (this.state.hasError) return <div>Retrying...</div>;
    return this.props.children(this.state.attempt);
  }
}

function UnstableComponent({ attempt }) {
  if (attempt < 2) throw new Error('Simulated Crash!');
  return <div>Loaded on attempt {attempt}</div>;
}

// Usage
<AutoRetryErrorBoundary>
  {(attempt) => <UnstableComponent attempt={attempt} />}
</AutoRetryErrorBoundary>

6. Virtualising Lists with Dynamic Item Heights

When you have huge lists, rendering every item can kill performance. Virtualisation libraries (like react-window) render only what’s visible. But what if items have unpredictable heights? You can measure them dynamically and feed those measurements back into your virtualisation logic. This reduces both memory usage and rendering time, keeping scrolling silky smooth.

Example:

import { VariableSizeList as List } from 'react-window';

function useDynamicMeasurement(items) {
  const sizeMap = React.useRef({});
  const refCallback = index => el => {
    if (el) {
      const height = el.getBoundingClientRect().height;
      sizeMap.current[index] = height;
    }
  };
  const getSize = index => sizeMap.current[index] || 50;
  return { refCallback, getSize };
}

function DynamicHeightList({ items }) {
  const { refCallback, getSize } = useDynamicMeasurement(items);
  return (
    <List height={400} itemCount={items.length} itemSize={getSize} width={300}>
      {({ index, style }) => (
        <div style={style} ref={refCallback(index)}>
          {items[index]}
        </div>
      )}
    </List>
  );
}

7. Using a State Machine for Complex UI Flows

When your component starts to feel like a spaghetti mess of if statements, a state machine can help. Tools like XState integrate nicely with React hooks. Instead of juggling multiple booleans, you define states and transitions in a single, clean chart. It’s a mental model shift: you’re writing a “map” of how your UI flows, making it easier to understand and debug.

Example:

import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

const formMachine = createMachine({
  initial: 'editing',
  states: {
    editing: { on: { SUBMIT: 'validating' } },
    validating: {
      invoke: {
        src: 'validateForm',
        onDone: 'success',
        onError: 'error'
      }
    },
    success: {},
    error: {}
  }
});

function Form() {
  const [state, send] = useMachine(formMachine, {
    services: { validateForm: async () => {/* validation logic */} }
  });
  return (
    <button onClick={() => send('SUBMIT')}>
      {state.value === 'editing' ? 'Submit' : state.value.toString()}
    </button>
  );
}

8. Controlled Concurrency with useTransition and Task Queues

React 18 introduced useTransition to help you mark certain state updates as “non-urgent.” This can be a game-changer for performance under heavy load. Imagine fetching large amounts of data or performing expensive calculations. By deferring non-urgent updates, you keep the UI responsive and avoid locking up the main thread.

Example:

function ComplexUI() {
  const [isPending, startTransition] = React.useTransition();
  const [data, setData] = React.useState([]);
  
  function loadMore() {
    startTransition(() => {
      setData(old => [...old, ...generateMoreData()]);
    });
  }
  return (
    <>
      <button onClick={loadMore}>Load More</button>
      {isPending && <span>Loading more data...</span>}
      <List data={data} />
    </>
  );
}

9. useImperativeHandle to Create Controlled Component APIs

Sometimes you need a parent component to directly control a child — like calling childRef.current.focus() on a custom input. useImperativeHandle is a hook that lets you define what a parent sees when it uses ref on a child component. This is perfect for creating neat, controlled component APIs that feel like calling a method on a class instance, but in a React-friendly way.

Example:

const FancyInput = React.forwardRef((props, ref) => {
  const inputRef = React.useRef();
  React.useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    getValue: () => inputRef.current.value
  }));
  return <input ref={inputRef} {...props} />;
});

function Parent() {
  const fancyRef = React.useRef();
  return (
    <>
      <FancyInput ref={fancyRef} />
      <button onClick={() => fancyRef.current.focus()}>Focus Input</button>
    </>
  );
}

10. Progressive Hydration with a Custom Hook

Server-side rendering (SSR) can get your content to the user fast, but hydrating a huge app all at once can slow interactivity. By delaying hydration for non-critical parts of your page, you can keep the initial load snappy. A custom hook that gradually hydrates certain components after a delay can make SSR feel even more seamless.

Example:

function useProgressiveHydration(delay = 1000) {
  const [hydrated, setHydrated] = React.useState(false);
  React.useEffect(() => {
    const t = setTimeout(() => setHydrated(true), delay);
    return () => clearTimeout(t);
  }, [delay]);
  return hydrated;
}

function HeavyComponent() {
  const hydrated = useProgressiveHydration();
  return hydrated ? <ExpensiveTree /> : <Placeholder />;
}

11. Combining Portals with Suspense for Heavy Modals

Modals are often large and complex. Instead of loading them upfront and bloating your bundle, you can lazy-load them. Combine this with React portals to render the modal outside your main DOM structure, keeping it clean and layered properly. Suspense handles the loading states, and the user never feels like they’re dealing with clunky code.

Example:

const HeavyModal = React.lazy(() => import('./HeavyModal'));

function PortalModal({ open }) {
  return open
    ? ReactDOM.createPortal(
        <React.Suspense fallback={<div>Loading Modal...</div>}>
          <HeavyModal />
        </React.Suspense>,
        document.body
      )
    : null;
}

12. Using useLayoutEffect for Smooth Layout Measurements

useEffect runs after the browser paints, which is fine for most things. But if you need to measure layout (like reading element positions or heights) and immediately adjust something before the user sees it, useLayoutEffect is your friend. It runs right after React updates the DOM but before the paint, ensuring no flicker.

Example:

function Popover({ anchorRef }) {
  const popoverRef = React.useRef();
  React.useLayoutEffect(() => {
    const anchorRect = anchorRef.current.getBoundingClientRect();
    popoverRef.current.style.top = `${anchorRect.bottom}px`;
  }, [anchorRef]);
  return <div ref={popoverRef} className="popover">Content</div>;
}

13. Using the React Profiler API for Dynamic Performance Tweaks

Did you know React has a Profiler API you can use in production to measure render times? You can detect slow components and dynamically adjust strategies — like increasing memoization thresholds or deferring certain updates — based on real-time metrics. Instead of guessing where the performance bottlenecks are, you measure and adapt.

Example:

import { unstable_Profiler as Profiler } from 'react';

function DynamicTuner() {
  const [threshold, setThreshold] = React.useState(10);
  function onRender(id, phase, actualDuration) {
    if (actualDuration > threshold) {
      // Adjust strategy based on performance
      setThreshold(t => t + 5);
    }
  }
  return (
    <Profiler id="App" onRender={onRender}>
      <MyAppComponents />
    </Profiler>
  );
}

14. Using useReducer with Immer for Immutable State Management

Managing deeply nested state can be a headache. Using useReducer gives you predictable updates, and mixing in the Immer library makes immutability a breeze. Instead of carefully cloning objects yourself, you can write “mutating” code inside produce() that results in a pure, immutable update. This can keep your reducers simple and reduce bugs.

Example:

import produce from 'immer';

function reducer(state, action) {
  return produce(state, draft => {
    if (action.type === 'ADD_ITEM') {
      draft.items.push(action.payload);
    }
  });
}

function ComplexList() {
  const [state, dispatch] = React.useReducer(reducer, { items: [] });
  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: 'X' })}>
      Add Item
    </button>
  );
}

15. Rendering Large SVG/Canvas Elements Efficiently with Windowing and Portals

If your app deals with massive charts, maps, or graphs, you might be dealing with huge SVG or canvas elements that slow down rendering. You can chunk these large visualizations and render only parts at a time — similar to how virtualization works for lists. Use portals and windowing techniques to break your large graphics into manageable pieces, so the browser isn’t overwhelmed.

function Segment({ color, target }) {
  return ReactDOM.createPortal(
    <svg width="200" height="100"><rect width="200" height="100" fill={color} /></svg>,
    target
  );
}

export default function App() {
  const containerRef = useRef(null);
  const [visible, setVisible] = useState([0,1]); // which segments are visible
  useEffect(() => {
    const onScroll = () => {
      const start = Math.floor(containerRef.current.scrollTop / 100);
      setVisible([start, start+1]);
    };
    containerRef.current.addEventListener('scroll', onScroll);
    return () => containerRef.current.removeEventListener('scroll', onScroll);
  }, []);

  return (
    <div ref={containerRef} style={{ height:200, overflowY:'auto' }}>
      {[...Array(10)].map((_, i) => <div key={i} id={'seg'+i} style={{height:100}} />)}
      {visible.map(i => <Segment key={i} color={i%2?'blue':'green'} target={document.getElementById('seg'+i)} />)}
    </div>
  );
}

Final Thoughts

These techniques aren’t meant to be used all at once, and certainly not in every codebase. They’re tools — advanced ones — that become helpful as your applications and your skills grow. Consider these approaches when you start running into the limitations of simpler patterns.

Reactjs
React
JavaScript
Javascript Tips
Front End Development
Recommended from ReadMedium