Coding in Flow logoGet my free React Best Practices mini course
← Blog

7 React Best Practices You Must Know as a Developer

Nov 9, 2023 by Florian

Featured image

In this post, I will share 7 best practices that will make you a better React developer. Check them out and test how many you already knew!

There is also a video course version of this post available. You can get it for free when you sign up to my email newsletter. You can find it at https://codinginflow.com/reactbestpractices.

And here is the GitHub repository with a separate branch for each best practice: https://github.com/codinginflow/react-best-practices

Table of Contents

Best Practice 1 - Use a modern framework

The first best practice is to use a modern React framework when you create a new project.

The React documentation lists a bunch of them on their installation page.

The framework provides the scaffolding for the project. A good framework makes sure that everything works out of the box. This is especially important when you want advanced features like the new React server components, which are not trivial to wire up. Good frameworks also add their own, additional features on top of the core React functionality. Different frameworks have different strengths and weaknesses.

My favorite is Next.js, which is a full-stack React framework that comes with server components, server actions, an integrated routing system, automatic image optimization, and other features. To set up a new Next.js project, you run a simple CLI command: npx create-next-app@latest. Just take a look at this huge list of companies that use Next.js. Big names like Nike and Netflix are on there.

Another tip is to use TypeScript for your projects. It has a bit of a learning curve when you're coming from JavaScript, but it makes working with large projects infinitely easier.

Best Practice 2 - Derive state when possible

When you have a value that is calculated from other values, you don't need to create a separate state and effect for that:

function MyComponent() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  // Unnecessary
  const [fullName, setFullName] = useState("");

  // Unnecessary
  useEffect(() => {
    setFullName(firstName + " " + lastName);
  }, [firstName, lastName]);

  [...]

}

This is a common beginner mistake. Not only does the useEffect cause an unnecessary additional rerender, the code is also difficult to understand. What's worse: If you modify the fullName state directly, it can even get out of sync with the firstName and lastName values.

The correct solution is to simply calculate the state directly in the body of the component:

function MyComponent() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = firstName + " " + lastName;

  [...]

}

You should use the same approach whenever you can calculate a value from existing state. This includes things like filtering lists.

Yes, this means the calculation will be executed on every render. For simple operations, like the string concatenation above, this doesn't matter. If you do a more expensive operation, wrap it into useMemo:

function MyComponent() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = firstName + " " + lastName;

  const vowelsMemoized = useMemo(() => {
    const chars = fullName.split("");
    const vowels = chars.filter((char) => "aeiou".includes(char));
    return vowels;
  }, [fullName]); // Block only runs again when fullName changes

  [...]

}

In the snippet above, we count the vowels in the user's name. It's just a random example I came up with. We still don't need a useEffect or useState. But since this operation is a bit more CPU-intensive, I wrapped it into useMemo. Note the dependency array that contains only fullName. This means the vowels value will only be calculated again when fullName changes. In every render between that, it will reuse the value it calculated before.

Don't use useMemo prematurely because it makes your code more complex. Only use it when you actually do a heavy calculation.

Best Practice 3 - Clean up your effects

You might have noticed that React renders your components twice in development. This is a feature, not a bug. It is part of React's strict mode, which is enabled by default in most React frameworks. It protects you from effects that don't clean up after themselves. Strict mode is only active in development, not in production.

The following useEffect has an empty dependency array, so it runs when the component mounts:

function MyComponent() {

  useEffect(() => {
    createConnection();
  }, []);

  [...]

}

But when we take a look at the logs in development, we can see that it connected twice:

A browser console log showing “connecting....“ and “connected“ twice in a row.

This, again, happens because of React's strict mode. But of course, there is a good reason it does that. Even in production, React can unmount and remount your component multiple times, for example, on navigation. Every time this happens, this useEffect runs again and this way creates multiple connections. This is almost never what you want.

The solution to this problem is to clean up our useEffect by returning a cleanup function from it. This function will run before the useEffect runs again and right before the component is unmounted. Here, we can close our connection, or perform any other cleanup:

function MyComponent() {

  useEffect(() => {
    createConnection();

    // Cleanup function
    return () => closeConnection();
  }, []);

  [...]

}

Now the useEffect still runs twice in development, but it closes the previous connection before creating a new one. This way, there will only ever be one active connection at a time:

A browser console log showing in order: “connecting...“, “connected“, “disconnecting...“, “disconnected“, “connecting...“, “connected“.

Use cleanup functions whenever your effect sets something up that should only be active once, like connections or event listeners:

useEffect(() => {
    function handleKeyPress(e: KeyboardEvent) {
      console.log("key pressed", e.key);
    }

    window.addEventListener("keypress", handleKeyPress);

    // Clean up your event listeners or they will stack up:
    return () => window.removeEventListener("keypress", handleKeyPress);
  }, []);

Best Practice 4 - Clean up your fetch requests

The following code uses a useEffect to search for products whenever the user changes the input in the search field. Can you detect the problem with this code?

import { useEffect, useState } from "react";

export default function SearchPage() {
  const [searchInput, setSearchInput] = useState("");
  const [products, setProducts] = useState<Product[]>([]);

  useEffect(() => {
    async function searchProducts() {
      const products = await fetchProducts(searchInput);
      setProducts(products);
    }
    if (searchInput) {
      searchProducts();
    } else {
      setProducts([]);
    }
  }, [searchInput]);

  return (
    <div>
      <input
        type="text"
        value={searchInput}
        onChange={(e) => setSearchInput(e.target.value)}
        placeholder="Search products..."
      />
      <div>
        {products.map((product) => (
          <div className="..." key={product.id}>
            <img
              className="..."
              src={product.thumbnail}
              alt="Product thumbnail"
            />
            <p>{product.title}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

At first glance, this looks fine. But what happens if fetchProducts sometimes runs slow? This will cause race conditions where the fetch response of a previous search query arrives after the response of the letters we typed in afterwards. These race conditions are more likely with a flaky internet connection and they make the UI glitch noticeably:

A search field that quickly jumps between different results lists due to a race condition in the search request.

Doesn't look great, does it? To fix this problem, you have to introduce a boolean flag that ignores the previous fetch request when the useEffect runs again. For this, we use the same useEffect cleanup function we learned about in the previous lesson:

useEffect(() => {
  let ignore = false;

  async function searchProducts() {
    const products = await fetchProducts(searchInput);
    if (!ignore) {
      setProducts(products);
    }
  }
  if (searchInput) {
    searchProducts();
  } else {
    setProducts([]);
  }

  return () => {
    ignore = true;
  };
}, [searchInput]);

The ignore flag above is scoped to the useEffect itself. So every instance of this useEffect will have its own ignore variable.

With this setup, whenever the useEffect runs again, the previous fetch request will simply be ignored.

Of course, this code is not very pretty. Especially when you also start adding loading and error states. This is why many people choose a dedicated fetching library like SWR or React-query instead of writing their own fetch requests inside useEffects. These libraries not only handle race conditions for you, they also add other useful features like caching, automatic revalidation, and more.

Best Practice 5 - Create custom hooks

When the logic in your React components becomes too big and complex, of course, you want to start moving it into separate files or functions.

All React functions that start with the word "use", like useEffect, useState, and useMemo, are called hooks in React. Hooks can only be called inside React components or other hooks.

To create a custom hook, you simply create a function that starts with the word "use". If you try to call a hook like useState or useEffect in a function that doesn't start with "use", ESLint will show an error. React enforces this naming convention so that developers can easily see which functions use hooks internally.

Let's move the logic of the previous example into a custom hook, including the useState and the useEffect with our cleanup flag:

export default function useSearchProducts(searchInput: string) {
  const [products, setProducts] = useState<Product[]>();

  useEffect(() => {
    let ignore = false;

    async function searchProducts() {
      const products = await fetchProducts(searchInput);
      if (!ignore) {
        setProducts(products);
      }
    }
    if (searchInput) {
      searchProducts();
    } else {
      setProducts([]);
    }

    return () => {
      ignore = true;
    };
  }, [searchInput]);

  // You can return values from the hook
  return { products };
}

In our component, we simply call this function, pass the search input to it, and destructure the return values we care about. This makes our code not only more readable but also more declarative because all the steps of the fetching process are abstracted away:

// In your components:
const { products } = useSearchProducts(searchInput);

Hooks are a great way to add separation of concerns to your React components and make component logic reusable.

Best Practice 6 - Create hooks for your context providers

Creating custom hooks is also great for making your own context providers easier to use. And they provide a nice way to handle undefined context values. To see what I mean, take a look at this context provider:

"use client";

import { createContext, useContext, useState } from "react";

interface ToastContext {
  showToast: (message: string) => void;
}

// Problem: No valid default value
export const ToastContext = createContext<ToastContext | undefined>(undefined);

export default function ToastProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [toastMessage, setToastMessage] = useState("");
  const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);

  function showToast(message: string) {
    setToastMessage(message);

    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    const newTimeoutId = setTimeout(() => {
      setToastMessage("");
    }, 3000);

    setTimeoutId(newTimeoutId);
  }

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      <div className={`... ${toastMessage ? "visible" : "invisible"}`}>
        {toastMessage}
      </div>
    </ToastContext.Provider>
  );
}

If you have never created your own React context before, here is a quick rundown of the single steps. We also build various context providers in my web development playlist on YouTube, where I give more detailed explanations.

The important part is the line towards the top that calls createContext. This function expects a default value that will be used if we try to access this context in a component that's not wrapped into the ToastProvider.

The problem is, often we don't have a meaningful default value that we can pass here. If we pass undefined as the default value, we have to handle a possibly undefined value whenever we access this context:

"use client";

import { useContext } from "react";
import { ToastContext } from "./ToastProvider";

export default function Page() {
  // Access the context
  const toastContext = useContext(ToastContext);

  return (
    <main>
      <button
    	// We have to use the safe-call operator because the context could be undefined:
        onClick={() => toastContext?.showToast("hello world")}
      >
        Show toast
      </button>
    </main>
  );
}

Not only does this look ugly, but we can also not use object destructuring on the toastContext because it could be undefined. And what's worse: If we indeed forget to wrap this component into the ToastProvider, the showToast function will just silently fail and do nothing. This can cause hard-to-detect bugs.

For these reasons, and for readability, you should always create custom hooks for your context providers:

// Custom hook
export function useToast() {
  const context = useContext(ToastContext);
  if (!context) {
    throw new Error("useToast must be used within a ToastProvider");
  }
  return context;
}

// Usage:
const { showToast } = useToast();
showToast("hello world")

Not only does this hook make our code more readable and our context easier to use, we also get a clear error message as soon as we open the page if we forgot to wrap this component into a ToastProvider. You always want your errors to show up as early as possible so you can quickly detect them.

Since the toast context is now guaranteed to be defined, we can also destructure the showToast function out of it.

You can put this hook in the same file as your context provider, that's what I like to do.

Best Practice 7 - Use fragments to inline multiple elements

Because of the syntax rules of JSX code, you sometimes need a single parent element in a place where you actually want to render multiple sibling elements. Here is an example:

function UserProfile() {
  const [showAddress, setShowAddress] = useState(false);

  return (
    <main className="p-4 space-y-1.5">
      <p>{user.name}</p>
      <p>{user.age}</p>
      <button onClick={() => setShowAddress(true)}>
        Show Address
      </button>
      {showAddress && (
        <div>
          <p>{user.address.street}</p>
          <p>{user.address.number}</p>
          <p>{user.address.city}</p>
          <p>{user.address.state}</p>
        </div>
      )}
    </main>
  );
}

Because the user's address is rendered conditionally inside an expression (showAddress &&), the code we put into the expression needs a single parent tag. If we remove the inner div, the code will not compile.

The problem is, now we have an unnecessary div in our HTML tree that also cancels out the styling we applied to the surrounding main tag. The space-y-1.5 class is not applied to the address paragraphs. Of course, we could add the same space-y class to the nested div, but this makes our code unnecessarily convoluted.

The better solution is to use a fragment instead of a div, by using an empty pair of <> </>:

function UserProfile() {
  const [showAddress, setShowAddress] = useState(false);

  return (
    <main className="p-4 space-y-1.5">
      <p>{user.name}</p>
      <p>{user.age}</p>
      <button onClick={() => setShowAddress(true)}>
        Show Address
      </button>
      {showAddress && (
        <>
          <p>{user.address.street}</p>
          <p>{user.address.number}</p>
          <p>{user.address.city}</p>
          <p>{user.address.state}</p>
        </>
      )}
    </main>
  );
}

The fragment provides a single parent element so that the compiler doesn't complain. However, the fragment will disappear from the resulting HTML. All the styling of the main tag will be applied directly to the address paragraphs:

An HTML tree containing a main tag surrounding 6 p tags that each show a line of a user address.

Wrap up

I hope you enjoyed my little list of React best practices. Again, there is a video version of this course available for free at https://codinginflow.com/reactbestpractices.

And you can find all my free long-form web development tutorials in this YouTube playlist.

See you in the next post! Happy coding!

Florian

Tip: I send regular high-quality web development content to my free email newsletter. It's a great way to improve your skills and stay ahead of the curve.

Subscribe Now

Check out these other posts: