Writing clean React

Learn how to write clean React code using Typescript with this guide.

·9 min read
Cover Image for Writing clean React

React started out as a simple library for building user interfaces. It has since become a bit more complex (for some, too much) and it's easy to write messy code.

In this guide, we'll go over some best practices for writing clean React code using Typescript. Most of these practices are widely used across all the Makerkit SaaS products.

Leverage Typescript

Typescript has overwhelmingly been a success for the React ecosystem, despite the initial competition with Flow. As of today, Typescript is likely the most common way of writing React code.

Knowing how to use Typescript well can be very important for ensuring your React codebase is clean and maintainable.

Stop using any

If you are using any, you are literally telling Typescript to ignore your code. As you may imagine, this is a bad practice and should be avoided at all costs.

It's okay if you're prototyping something quickly, but you should ensure you go back and fix it late to avoid it from causing issues in your codebase later on.

If the shape of your types is truly unknown, then a better alternative would be using unknown, which is a safer alternative to any, or Record<string, unknown> if you're dealing with an object.

Stop using magic strings and numbers

While not technically a Typescript issue, it's a common mistake to use magic strings and numbers in your code: for example, status === 'loading' or status === 200, where loading and 200 are magic strings and numbers.

For example, if you're using a status property, you can consider using an enum instead of a string:

enum Status {
  Loading = 'loading',
  Error = 'error',
  Success = 'success',
}

Since enum tends to be quirky in Typescript, you can also use a type instead:

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

const LOADING: Status = 'loading';

Or simply an object:

const STATUS = {
  Loading = 'loading',
  Error = 'error',
  Success = 'success',
};

Use strong-typed components Props

If you're using Typescript, you should always strongly type your components' props: this will help you catch errors early, make your code more readable, and even code faster with the help of your IDE since it will be able to infer the possible values of your properties.

Strong Typing components with the const keyword

To strong-type your components, you have various options. The most common one is to use the FC type from React. This type takes a generic parameter that represents the type of the props.

const Button: React.FC<React.PropsWithChildren<{
  id: string;
}>> = ({ children, id }) => {
  return <button id={id}>{children}</button>;
};

In the example above, we're using the PropsWithChildren type to add the children property to our props. This is because the FC type doesn't include the children property by default in React 18. Additionally, we provide a type for the id property that is a string.

If the above is too verbose, you can declare a global type, such as React.FCC:

declare global {
  namespace React {
    type FCC<P = {}> = FC<React.PropsWithChildren<P>>;
  }
}

So we can rewrite the above example as:

const Button: React.FCC<{
  id: string;
}> = ({ children, id }) => {
  return <button id={id}>{children}</button>;
};

That makes it more readable and easier to use.

Strong Typing components with the function keyword

If you are using the function keyword to declare your components, you can use the React.PropsWithChildren type:

function Button(
  { children, id }: React.PropsWithChildren<{ id: string }>
) {
  return <button id={id}>{children}</button>;
}

Strong Typing components complex props

When the props get a bit complex, consider declaring a separate type for them. For example, this can be the case when you declare more than 2 or 3 properties, or when you have functions with complex signatures.

type ButtonProps = {
  id: string;
  className: string;
  onClick: () => void;
};

const Button: React.FCC<ButtonProps> = ({ children, className, id, onClick }) => {
  return (
    <button className={className} id={id} onClick={onClick}>
      {children}
    </button>
  );
};

Components

Write Smaller Components

When writing components, it's easy to write large components that do too many things. However, this makes the component hard to understand, to reuse, and can hide subtle bugs that are not easy to find.

There isn't a hard rule on how big a component should be, but it's generally a good idea to keep components small and focused on a single task.

When a component can be split into smaller components, also consider using Namespaced Components, as described below.

Namespaced Components

When your component can be split into smaller atoms, you can consider namespacing the root component and exporting one single component, rather than exporting multiple components.

There are two main benefits to this approach:

  1. It's easier to import the component, since you only need to import one component
  2. It's easier to understand the component, since it's all in one place
  3. No need to worry about naming collisions with other components

For example, consider the following component Alert, which we can split into Alert and AlertHeading:

const Alert: React.FCC<{
  type: 'success' | 'error' | 'warn' | 'info';
  className?: string;
}> & {
  Heading: typeof AlertHeading;
} = ({ children, type, className }) => {
  const [visible, setVisible] = useState(true);

  if (!visible) {
    return null;
  }

  return (
    <div className={`Alert ${colorClassNames[type]} ${className ?? ''}`}>
      <span className={'flex items-center space-x-2'}>
        <span>{children}</span>
      </span>
    </div>
  );
};

function AlertHeading({ children }: React.PropsWithChildren) {
  return (
    <div className={'mb-2 flex items-center space-x-2'}>
      <Heading type={6}>
        <span className={'font-semibold'}>{children}</span>
      </Heading>
    </div>
  );
}

Alert.Heading = AlertHeading;

export default Alert;

As you can see, we've added the component AlertHeading as a property of the Alert component.

This way, we can import the component as a single component:

import Alert from `~/core/ui/Alert`;

return (
  <Alert>
    <Alert.Heading>
      Lorem ipsum dolor sit amet
    </Alert.Heading>

    <p>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
      tincidunt, nisl eget aliquam tincidunt, nisl elit aliquam
      tortor, eget aliquam tortor nisl eget lorem.
    </p>
  </Alert>
);

You will notice that many UI libraries use the same pattern.

Data-Fetching Components vs Rendering Components

When you have a component that fetches data, you can consider splitting it into two components: one for fetching the data, and one for rendering the data.

Sometimes, you can also consider creating Components that act as data providers, and then return the data as a render prop.

For example, consider the following component that fetches data from Firestore:

function OrganizationDataProvider(
  { 
    organizationId,
    children
  }: { 
    organizationId: string;
    children: (data: Organization) => React.ReactNode;
  },
) {
  const { data, status } = useFetchOrganization(
    organizationId
  );

  if (status === 'loading') {
    return <Loading />;
  }

  if (status === 'error') {
    return <Error />;
  }

  return children(data);
}

This component is responsible for fetching the data; then, it passes the data to its child component. This way, we can reuse the component to fetch data from Firestore, and then render it in different ways.

<OrganizationDataProvider organizationId='1'>
  {data => <OrganizationCard data={data} />}
</OrganizationDataProvider>

Or we can use it to render the data differently:

<OrganizationDataProvider organizationId='1'>
  {data => 
    <Heading type={1}>{data.name}</Heading>
  }
</OrganizationDataProvider>

Hooks

Encapsulate complex logic in custom hooks

When you have complex logic that is used in multiple components, consider encapsulating it in a custom hook: explicitly naming the hook will make it easier to understand what it does.

For example, assume we want to fetch data using Firestore within a component. We can encapsulate this logic in a custom hook:

export function useFetchOrganization(
  organizationId: string
) {
  const firestore = useFirestore();

  const ref = doc(
    firestore,
    ORGANIZATIONS_COLLECTION,
    organizationId
  ) as DocumentReference<Response>;

  return useFirestoreDocData(ref, { idField: 'id' });
}

export default useFetchOrganization;

By hiding the complexity of fetching data from Firestore, it's immediately clear that the hook is used to fetch an organization. Then, it becomes a one-liner within the component:

function OrganizationComponent() {
  const { data, status } = useFetchOrganization('org-id');

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (status === 'error') {
    return <div>Error</div>;
  }

  return <div>{data.name}</div>;
}

Stop over-using useEffect

The useEffect hook is a powerful tool that allows you to run side effects in your components. However, it's easy to overuse it and end up with a lot of side effects that are hard to understand, and can potentially make your app (very) buggy.

In fact, frameworks like Remix have built-in data fetching capabilities that help us fetch data without ever needing to use the useEffect hook. With that said, it's not always necessary.

The new React Beta documentation has an in-depth guide on when to use the "useEffect" hook, and when not to use it. I highly recommend reading it.

Use useCallback when passing functions as props

When passing functions as props, you should use the useCallback hook to memoize the function.

Assuming a child component uses the closure within a useEffect hook, it will cause the useEffect to run on every render. This is because the function is recreated on every render, and the useEffect hook will compare the function references to determine if it should run: this is bad.

Assuming the below:

function Parent() {
  const success = () => {
    // do something
  };

  return <Child onSuccess={success} />;
}

function Child({ onSuccess }: { onSuccess: () => void }) {
  useEffect(() => {
    // assuming some condition here
    onSuccess();
  }, [onSuccess]);

  return <div>...</div>
}

We can fix this by using the useCallback hook:

function Parent() {
  const success = useCallback(() => {
    // do something
  }, []);

  return <Child onSuccess={success} />;
}

While I personally use useCallback every time I define a function within a component, it's not always necessary.

Use useMemo when passing objects as props

Similarly to useCallback, you should use useMemo when passing objects as props.

By using useMemo, you will prevent the object from being recreated on every render and will prevent firing the useEffect hook on every render when the object is passed as a dependency:

function Parent() {
  const obj = useMemo(() => ({ foo: 'bar' }), []);

  return <Child obj={obj} />;
}

Avoid multiple useState hooks

When you have multiple useState hooks, it's easy to lose track of the state, and often lead to over-rendering your components.

There are multiple ways to solve this:

  1. Use a single useState hook with an object
  2. Use the useReducer hook, which is a more powerful alternative to useState that allows you to manage more complex state using reducers
  3. Use a library like Zustand, which is lightweight and easy to use

Whichever option you use, make sure to keep your state as simple as possible.

You may not need useState at all

Additionally, remember that sometimes you don't need to use state at all: in many cases, useRef is what you actually need if you want to store a value that persists between renders but that doesn't trigger a re-render.

Conclusion

In this article, we've covered some best practices for writing clean React code, including Typescript basics, components and hooks.

I hope this article was helpful. If you have any questions, feel free to reach out!


Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor

Read more about

Cover Image for How to sell code with Lemon Squeezy and Github

How to sell code with Lemon Squeezy and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Lemon Squeezy
Cover Image for How to use MeiliSearch with React

How to use MeiliSearch with React

·12 min read
Learn how to use MeiliSearch in your React application with this guide. We will use Meiliseach to add a search engine for our blog posts
Cover Image for Setting environment variables in Remix

Setting environment variables in Remix

·3 min read
Learn how to set environment variables in Remix and how to ensure that they are available in the client-side code.
Cover Image for Programmatic Authentication with Supabase and Cypress

Programmatic Authentication with Supabase and Cypress

·3 min read
Testing code that requires users to be signed in can be tricky. In this post, we show you how to sign in programmatically with Supabase Authentication to improve the speed of your Cypress tests and increase their reliability.
Cover Image for Reset the Supabase Database in Cypress

Reset the Supabase Database in Cypress

·4 min read
Resetting your database during E2E tests is important to prevent flakiness. In this tutorial, we'll show you how to reset the Supabase database in Cypress E2E tests.
Cover Image for Authenticating users with Remix and Supabase

Authenticating users with Remix and Supabase

·16 min read
Learn how to use Remix and Supabase to authenticate users in your application.