Frontend React

Declarative Paradigm

One of the most important things to understand early on about React is that it follows a declarative programming paradigm. This sets it apart from earlier, imperative approaches like jQuery, as well as from traditional object-oriented programming commonly found in languages like Java and C++.

When writing a User Interface (UI) in React, the goal is to define the minimal but complete data model for it, map the model to UI components and then change the data upon user interaction and the UI will be updated automatically (on next re-render).

To give a practical example:
Instead of directly changing a button’s background color to red on click, as done in imperative code, React encourages defining a state variable that represents the background color. This variable is then bound to the button’s style in JSX, and its value is updated in response to the click event — allowing React to handle the UI update declaratively.

There are so-called escape hatches from the declarative approach in React:

  • useRef - for keeping non-reactive values between re-renders
  • useEffect - for hooking into component's lifecycle
  • useImperativeHandle - for defining imperative API of components

Escape hatches are completely fine to use when appropriate, and sometimes they are the only (performant) way of achieving desired functionality, however the declarative approach is much less error-prone and should be preferred in most cases.

Further reading:

Components

React is built around the concept of composing reusable components.
When implementing a new part of the UI, the first step should be to examine the mockup and break it down into small, focused components — each ideally with a single responsibility. For example, this could mean creating one component for displaying text, another for rendering a list of rows, and so on.

Some of the best practices regarding writing components in React are:

  • Use only functional components (no class components, with reasonable exceptions e.g. ErrorBoundary)
  • Component should be focused on a single responsibility
  • Encourage reusability and extensibility
  • Break down UI into small components with a single responsibility
  • Don't define components during render

Don't define components during render

The last point deserves a closer look. When working with container components (see glossary) it’s often important to allow customization of individual elements like rows or cells, depending on how the component is used. This is typically done in one of two ways:

  1. Render function prop that accepts arguments that Container provides (index, total count etc) and returns JSX: renderRow: (args) => ReactNode
  2. (Functional) component definition prop: RowTemplate: React.FC<TArgs>

The first approach is simpler, but it doesn't allow for performance optimizations using memoization. That means any time the container re-renders, the renderRow function is called again for all visible rows.

In contrast, the second approach supports memoization by defining RowTemplate as a React functional component. When wrapped with React.memo, React can avoid re-rendering rows whose props haven’t changed. However, because RowTemplate is treated as a full component, it follows React’s rendering rules. If RowTemplate is defined inline during render — for example, as an arrow function — its reference will change on every render. This causes React to treat it as a new component type, which leads to the entire subtree being discarded and re-rendered from scratch.

type ListProps = {
  items: unknown[];
  ItemTemplate: React.FC<{ item: unknown }>;
};

const List = ({ items, ItemTemplate }: ListProps) => {
  return (
    <>
      {items.map((item, index) => (
        <ItemTemplate key={index} item={item} />
      ))}
    </>
  );
};

// ItemTemplate's reference is changed on each rerender of MyList
const MyList = () => {
  return (
    <List
      items={[1, 2, 3]}
      ItemTemplate={(item) => <div>{String(item)}</div>}
    />
  );
};

Further reading:

State Management

  • Keep a single source of truth. Duplication of data very often leads to inconsistent application state.
  • Avoid contradictions in state. E.g. holding isMonday and isTuesday can lead to contradicting state when both become true. The better alternative would be to hold one state dayOfWeek.
  • Avoid redundant state. If some information can be efficiently derived from component’s props or its other state variables during rendering, it should not be put into that component’s state.
  • When using zustand store, select only strictly necessary data. Utilizing selectors efficiently can remove the need for a great deal of memoization.
  • Avoid using React.Context for holding big or complex objects. React.Context API provides no way of optimizing re-renders by picking only part of state.
    However, there are some advantages of React.Context:
    • Value held in React.Context will be automatically cleaned up when respective Provider is unmounted.
    • There is smaller chance of using the value from React.Context where it was not intended.
      If useSomeContext is called not under SomeContext.Provider, the hook will crash or return nullish value (depending on its implementation).

Further reading:

Hooks

  • Don't overuse useEffect.
    Always understand when exactly your useEffect will be called. Know the difference between no dependencies and empty array of dependencies.
  • Use return value of useEffect for cleanup when:
    • adding event listener (element.addEventListener())
    • using setTimeout or setInterval
  • Don't use useState for non-reactive values. Instead, useRef can be used.
  • When performing layout calculations in useEffect and experiencing flickering, consider using useLayoutEffect instead of useEffect. The former is executed BEFORE the render, while the latter is executed AFTER render.
    Note: overuse of useLayoutEffect negatively affects performance since it slows down render cycle.
  • Use useTransition for non-blocking but expensive state updates.
  • There is an experimental useEffectEvent hook, however it can be quite easily constructed and some libraries implement it themselves. Its purpose is similar to a useEffect which can READ the latest values of state and props without necessarily adding all of them as dependencies and thus triggering useEffect when any of them change.
  • If a useEffect only aims to read reactive value without needing to be triggered when a reactive value changes, in order to avoid adding reactive value to dependencies, useRef can be utilized:
const freshValueRef = useRef();

// runs on every render
freshValueRef.current = freshValue;

useEffect(() => {
  // do logic using freshValueRef.current
}, [freshValueRef]);
  • Extract reusable logic to custom hooks, but always understand the effect that using a custom hook has on component re-renders. A helpful way to understand this effect is to simply copy-paste the code inside a custom hook into the component that uses it and evaluate how it affects re-renders.
  • Keep in mind React's rules of hooks

Further reading:

Memoization

Memoization (optimization of re-renders) is by far the most common way of optimizing performance of React apps.
It is true that the render process is much faster than changing the actual DOM and most of the time an extra re-render would not matter at all. However, performance problems do arise when too many re-renders of too many components want to happen at the same time.

Some of the best practices regarding memoization in React are:

  • Don't memoize prematurely.
  • Don't break memoization of already memoized components. Make sure that you keep new function- or object-types props of memoized components stable.
  • Always evaluate whether memoization is needed in container components. See Most common memoization use-cases for more details.
  • When using React.memo, define component's displayName (e.g. Input.displayName = "Input"; at the end of your file).
  • Don't define non-primitive props (functions, objects) during render when passing them to memoized components. Functional props should be memoized with useCallback since functions can only be compared by reference. Object props should be memoized with useMemo or instead a deep equality function should be provided as second argument of React.memo to ensure objects will be compared by value.
  • Never exclude some prop from comparison in a custom equals function provided to React.memo - it might result in hard-to-fix stale UI bugs.
  • Don't memoize primitive values with useMemo, unless their computation is extremely inefficient.

Most common memoization use-cases

A common industry guideline is to avoid premature optimization. This means you should not add memoization until you have identified an actual performance issue that needs to be addressed. However, there are some common scenarios where memoization is almost always needed.

Memoization is usually necessary in large, flat component structures and in deep hierarchies where updates can propagate through many layers.
The former scenario can occur when UI has List-, Table- or Grid-like layout. A re-render of container component by default will cause re-render of each row / cell / tile.
The latter is more subtle and usually is discovered empirically but one example could be a top level component like a Page.
When a new dialog needs to be added to a page, toggling its open state would cause the entire page and all its non-memoized children to re-render, including potentially expensive components like complex headers, toolbars or hidden drawers.

To evaluate the need for optimization, use browser developer tools (e.g., enable CPU throttling in Chrome DevTools) and perform actions that could trigger excessive re-renders, such as updating a list or opening a dialog. Test with the maximum expected data to ensure performance remains acceptable.

Further reading: