Best Practices for managing and optimizing React components

Published on
Best Practices for managing and optimizing React components

Some best practices for managing and optimizing React components

Hard coded values

Let's start talking about constant values or hard-coded values. We usually have default values that are hard coded (they don't change) and needed in different files or components.

In our example we may have a value that indicates what is the maximum number of todos before the user is asked to login in order to add more. What if we change our mind in the future and allow our users more todos?

We could also have a list of words that we know might be sensitive information and let the user know we can't process the request.

What we typically do in these scenarios is create a separate variable outside our component or file to store these values, this helps us to globally change a value without needing to find it in multiple files or lines of code. A common standard is to uppercase the word in Snake CaseExternal Link.

Lastly if we have initial constant data in an array or object inside the component, that data will be recreated on every render, for a small array or object it might not be a problem but it is something to keep in mind. A better solution will be to move the constant values outside the component and that also helps keep the component clean.

export const MAX_FREE_TIER = 5;
export const SENSITIVE_WORDS = ['password123', 'credit card', 'admin'];
@/app/lib/constants.ts
'use client'; // used only for nextjs

import React, { useState } from 'react';
import { MAX_FREE_TIER, SENSITIVE_WORDS } from '@/app/lib/constants';

type Todo = {
  id: number;
  content: string;
  completed: boolean;
};

// const MAX_DATA = 10;

const initialData = [
  {
    id: 1,
    content: 'Check car oil',
    completed: false,
  },
  {
    id: 2,
    content: 'Check account balance',
    completed: true,
  },
];

export default function HardCodedValues() {
  const [todos, setTodos] = useState<Todo[]>(initialData);
  const [content, setContent] = useState('');

  return (
    <section>
      {todos.map((todo) => (
        <div key={todo.id}>{todo.content}</div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault();

          if (todos.length === MAX_FREE_TIER) {
            console.log(`You need to sign in to add more than ${MAX_FREE_TIER} todos`);
            return;
          }

          if (SENSITIVE_WORDS.includes(content)) {
            console.log('Cannot save sensitive information');
            return;
          }

          setTodos((prev) => [
            ...prev,
            { id: prev.length + 1, content: content, completed: false },
          ]);

          setContent('');
        }}>
        <input
          name='content'
          placeholder='Type here'
          value={content}
          onChange={(event) => setContent(event.target.value)}
        />
        <button
          type='submit'
          className='py-2 px-4'>
          Submit
        </button>
      </form>
    </section>
  );
}
tsx

Layout styles in reusable components

Try not to add layout styles to reusable components.

In our example we have a header component that is used in multiple places. This component might have a problem with spacing in some places (it might appear too close to other content).

A naive solution at first might be to write layout styles (e.g. margin, flexbox, grids) in the reusable component, but that could cause more problems with the rest of the content.

A second solution could be to wrap the component in a single div and apply styles there to space the header from the other content.

import React from 'react';

type HeaderProps = {
  children: React.ReactNode;
};

const Header = ({ children }: HeaderProps) => {
  return <h1 className='text-3xl lg:text-6xl font-bold tracking-tight'>{children}</h1>;
};

export default function LayoutInReusableComponents() {
  return (
    <section className='flex flex-col items-center'>
      <div className='mb-4'>
        <Header>This is a nice header</Header>
      </div>
      <ul>
        <li>Task 1: Water the plants</li>
        <li>Task 2: Buy groceries</li>
        <li>Task 3: Finish reading the book</li>
      </ul>
    </section>
  );
}
tsx

And another solution is to just accept an optional class prop inside our headers component and maintain all the other base classes intact.

import React from 'react';

type HeaderProps = {
  children: React.ReactNode;
  className?: string;
};

const Header = ({ children, className }: HeaderProps) => {
  return (
    <h1 className={`text-3xl lg:text-6xl font-bold tracking-tight ${className}`}>
      {children}
    </h1>
  );
};

export default function LayoutInReusableComponents() {
  return (
    <section className='flex flex-col items-center'>
      <Header className='mb-10'>This is a nice header</Header>
      <ul>
        <li>Task 1: Water the plants</li>
        <li>Task 2: Buy groceries</li>
        <li>Task 3: Finish reading the book</li>
        <li>Task 4: Call Mom</li>
        <li>Task 5: Schedule a dentist appointment</li>
      </ul>
    </section>
  );
}
tsx

Keeping reusable components simple

Imagine we have a parent component that manages a list of products and we want to display the total price of all the products.

We create a child component ProductSummary that summarizes the total price of the products.

'use client'; // used only for nextjs

import React, { useState } from 'react';

type Product = {
  id: number;
  name: string;
  price: number;
};

function ProductSummary({ products }: { products: Product[] }) {
  // Derived state: Calculate the total price of all products
  const totalPrice = products.reduce((total, product) => total + product.price, 0);

  return (
    <>
      <h3>Summary</h3>
      <p>Total Products: {products.length}</p>
      <p>Total Price: ${totalPrice}</p>
    </>
  );
}

export default function ProductList() {
  const [products] = useState([
    { id: 1, name: 'Laptop', price: 999 },
    { id: 2, name: 'Smartphone', price: 499 },
    { id: 3, name: 'Tablet', price: 299 },
  ]);

  return (
    <section>
      <h2>Product List</h2>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
      {/* Passing the entire list of products to the child component */}
      <ProductSummary products={products} />
    </section>
  );
}
tsx

Now, what could possibly be a bad practice in this case?

For one, if the summary gets state that causes a rerender, the total price will be recalculated even if it's not necessary.

Another problem could be that now the child component is now tightly coupled with the parent structure and the products array. If we wanted to reuse the component in a different page or component we would need to change the logic of the child component making it harder to maintain.

A better approach will be to calculate the summary in the parent component. The child component now only needs to display the data it receives, making it simpler and more focused on presentation.

'use client'; // used only for nextjs

import React, { useState } from 'react';

function ProductSummary({
  totalPrice,
  productCount,
}: {
  totalPrice: number;
  productCount: number;
}) {
  return (
    <>
      <h3>Summary</h3>
      <p>Total Products: {productCount}</p>
      <p>Total Price: ${totalPrice}</p>
    </>
  );
}

export default function ProductList() {
  const [products] = useState([
    { id: 1, name: 'Laptop', price: 999 },
    { id: 2, name: 'Smartphone', price: 499 },
    { id: 3, name: 'Tablet', price: 299 },
  ]);

  // Calculate total price in the parent component
  const totalPrice = products.reduce((total, product) => total + product.price, 0);

  return (
    <section>
      <h2>Product List</h2>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
      {/* Pass the calculated total price as a prop */}
      <ProductSummary
        totalPrice={totalPrice}
        productCount={products.length}
      />
    </section>
  );
}
tsx

This is just one of many use cases, sometimes having a derived state in a child component is fine. A child component can have its own logic and might be easier to reason about its behavior without needing to consider the parent's state or logic.

A parent component might have a lot of states to manage and a child component will help handle specific logic and lead to cleaner code separation.

Children Prop pattern

Lets analyze the next example:

import React from 'react';

const Grandchild = ({ message }: { message: string }) => {
  return <div>{message}</div>;
};

const Child = ({ message }: { message: string }) => {
  return (
    <div className="flex flex-col">
      <h1>This is a child component</h1>
      <Grandchild message={message} />
    </div>
  );
};

export default function Container() {
  return (
    <section className='container'>
      <Child message='Hello world!'></Child>
    </section>
  );
}
tsx

In this example we can see that the Grandchild component does not depend on any local variables inside the Child component And the message prop is just passed down and the Child component doesn't need it.

The children pattern allows us to pass components or data directly from a parent component to intermediate components without them needing to know about it.

The children prop is a special prop in React. It automatically populates the content between the opening and closing tags of the component.

Here's how you can refactor the above example:

'use client'; // used only for nextjs

import React from 'react';

const Grandchild = ({ message }: { message: string }) => {
  console.log('Grandchild re-render');
  return <div>{message}</div>;
};

const Child = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = React.useState(0);
  console.log('Child re-render');

  return (
    <div className='flex flex-col'>
      <h1>This is a child component</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      {children}
    </div>
  );
};

export default function Container() {
  return (
    <section className='container'>
      <Child>
        <Grandchild message='Hello world!' />
      </Child>
    </section>
  );
}
tsx

This pattern reduces the need to pass props through multiple layers, making the code easier to maintain and the components more reusable.

It also comes with the benefit of performance, in our example Grandchild will not rerender when the Child component rerenders because it is not part of the Child's component tree. Grandchild belongs to the Container component.

We have to keep in mind that this pattern only works when the children's elements do not depend on their parents' internal variables.

A small downside might be that the container component becomes more bloated.

Memoization

Everytime components rerender when state/props change, depending on how complicated or if an expensive computation is needed, that rerender might be slow.

To help us with this situation React provides us useMemo, useCallback and React.memo

Imagine we have a component showing users, we might have hundreds of users to show, so every time the users change, a rerender will be triggered and also all the component statements will run again.

'use client'; // used only for nextjs

import React, { useState } from 'react';

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

const ToggleUserStatus = ({ toggleUser }: { toggleUser: () => void }) => {
  return (
    <button
      className='px-4 py-2 bg-orange-400' onClick={toggleUser}>
      Toggle user
    </button>
  );
};

type User = {
  id: number;
  active: boolean;
  name: string;
};

const ShowUsers = ({ users, activeUsers }: { users: User[]; activeUsers: number }) => {
  return (
    <>
      <p>Active users: {activeUsers.toFixed(1)}%</p>
      {users.map((user) => (
        <p key={user.id}>
          {user.name}: {user.active + ''}
        </p>
      ))}
    </>
  );
};

export default function Memoization() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  // This computation will run on every rerender
  const activeUsers = (users.filter((user) => user.active).length / users.length) * 100;

  // A new function is created and assigned to this variable on each rerender
  const toggleUser = () => {
    const randomId = Math.floor(Math.random() * users.length);

    const updatedUser = users.map((user, index) =>
      index === randomId ? { ...user, active: !user.active } : user
    );
    setUsers(updatedUser);
  };

  return (
    <section>
      <p>count: {count}</p>
      <button
        className='px-4 py-2 bg-slate-500'
        onClick={() => setCount((prev) => prev + 1)}>
        Increment {count}
      </button>
      <ToggleUserStatus toggleUser={toggleUser} />
      <ShowUsers
        users={users}
        activeUsers={activeUsers}
      />
    </section>
  );
}
tsx

So to avoid expensive computations from running everytime we can use the useMemo hook. It is often used for calculations, objects arrays or derived states (values derived from existing state).

We can also use the useCallback hook for functions, useCallback is syntactic sugar. It exists purely to make our lives a bit nicer when trying to memoize callback functions.

In our example a new function is created and assigned to a variable on each rerender but by wrapping it in useMemo the function will be cached.

And at last we can also memoize components using memo, if props do not change, the component will be cached and not rerender. In our example since we are using useCallback on the toggleUser function, the function will remain the same between rerenders and therefore the ToggleUserStatus component will stay the same.

'use client';

import React, { memo, useCallback, useMemo, useState } from 'react';

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

const ToggleUserStatus = memo(function ToggleUser({
  toggleUser,
}: {
  toggleUser: () => void;
}) {
  return (
    <button
      className='px-4 py-2 bg-orange-400'
      onClick={toggleUser}>
      Toggle user
    </button>
  );
});

type User = {
  id: number;
  active: boolean;
  name: string;
};

const ShowUsers = memo(function ShowUsers({
  users, activeUsers,
}: {
  users: User[]; activeUsers: number;
}) {
  return (
    <>
      <p>Active users: {activeUsers.toFixed(1)}%</p>
      {users.map((user) => (
        <p key={user.id}>
          {user.name}: {user.active + ''}
        </p>
      ))}
    </>
  );
});

export default function Memoization() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  // This computation will run on every rerender without useMemo
  const activeUsers = useMemo(
    () => (users.filter((user) => user.active).length / users.length) * 100,
    [users]
  );

  // A new function is created and assigned to this variable on each rerender without useCallback
  const toggleUser = useCallback(() => {
    const randomId = Math.floor(Math.random() * users.length);
    const updatedUser = users.map((user, index) =>
      index === randomId ? { ...user, active: !user.active } : user
    );
    setUsers(updatedUser);
  }, [users]);

  return (
    <section>
      <p>count: {count}</p>
      <button
        className='px-4 py-2 bg-slate-500'
        onClick={() => setCount((prev) => prev + 1)}>
        Increment {count}
      </button>
      <ToggleUserStatus toggleUser={toggleUser} />
      <ShowUsers
        users={users}
        activeUsers={activeUsers}
      />
    </section>
  );
}
tsx

When clicking on the increment button on the parent component, it doesn't trigger a rerender in the two child components

Note: Using these hooks could change in the future. The React team is actively investigating whether it's possible to “auto-memoize” code during the compile step. It's still in the research phase.

useState updater function

If we have a state that is changing and it depends on the previous value we should use the functional way of setting state. For example:

setUsers((prev) => [...prev, { id: 4, active: true, name: "Mike" }]);
tsx

We want to keep the previous values by spreading them and add or update the new value.

Doing it like this we avoid problems with old values being rendered instead of the expected new ones.

'use client';

import React, { useEffect, useState } from 'react';

const allUsers = [
  { id: 1, active: true, name: 'Maria' },
  { id: 2, active: true, name: 'John' },
  { id: 3, active: true, name: 'Nataly' },
];

export default function SetterFunction() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(allUsers);

  useEffect(() => {
    setUsers((prev) => [...prev, { id: 4, active: true, name: 'Mike' }]);

    const intervalId = setInterval(() => {
      setCount((prev) => prev + 1);

      // Doing it like this will not work
      // setCount(count + 1);
      // setCount(count + 1);
      // setCount(count + 1);
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, []);

  const handleClick = () => {
    setCount((prev) => prev + 1);
    setCount((prev) => prev + 1);
  };

  const handleClickAsync = () => {
    setTimeout(() => {
      setCount((prev) => prev + 1);
    }, 1000);
  };

  return (
    <section>
      <p>{count}</p>
      <div>
        {users.map((user) => (
          <p key={user.id}>{user.name}</p>
        ))}
      </div>
      <button
        onClick={handleClick}
        className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add two more
      </button>
      <button
        onClick={handleClickAsync}
        className='bg-blue-500 px-4 py-2 text-white rounded'>
        Add number after 1 sec
      </button>
    </section>
  );
}
tsx

Single State

Instead of having multiple states for closely related features or logic consider using a single state.

In our example if we have a loading, error and success states, they can be united into a single state.

'use client';

import React, { useState } from 'react';

type Status = 'init' | 'loading' | 'error' | 'success';

export default function UnionTypes() {
  const [status, setStatus] = useState<Status>('init');
  return <section>{status}</section>;
}
tsx

One source of Truth

Keep track of active or selected items by its id and not by copying the whole item.

We might be tempted to copy a whole item when selecting or toggling an item in a list. But if that item changes in the future we will have two different sources of truth. They will be out of sync and we will need to update both states to keep them in sync.

If we need the whole item we can just derive state and get the whole object.

'use client';

import React, { useState } from 'react';

type User = {
  id: number;
  name: string;
};

export default function SingleTruth() {
  const [users, setUsers] = useState([
    { id: 1, name: 'Nataly' },
    { id: 2, name: 'John' },
    { id: 3, name: 'Alice' },
  ]);

  const [activeUser, setActiveUser] = useState<number | null>(null);

  const selectedUser = users.find((user) => user.id === activeUser);

  const handleSelectUser = (user: User) => {
    setActiveUser(user.id);
  };

  return (
    <div>
      <h2>Select a User</h2>
      {users.map((user) => (
        <button
          className='bg-slate-500 px-4 py-2 m-1'
          key={user.id}
          onClick={() => handleSelectUser(user)}>
          {user.name}
        </button>
      ))}

      {selectedUser ? (
        <div>
          <h3>Active User:</h3>
          <p>{selectedUser.name}</p>
        </div>
      ) : (
        <div>
          <h3>No user selected</h3>
        </div>
      )}
    </div>
  );
}
tsx

Use Url params instead of state

When we have information like filters, variants, or pagination, putting the information in the url may be a better option, we can even share the url and get the exact same view we were looking at. Besides sharing, people could also bookmark the page and come back to the same view.

In our example with Next.jsExternal Link we are setting a url param using an input, we could later use this information to filter a table.

'use client';

import React from 'react';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';

export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
  return (
    <input
      placeholder={'search'}
      onChange={(e) => {
        handleSearch(e.target.value);
      }}
      defaultValue={searchParams.get('query')?.toString()}
    />
  );
}
tsx