Writing clean React

Level up your React coding skills with Typescript using our comprehensive guide on writing clean code. Start writing clean React code, today.

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> ); };


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>


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.


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!

Read more about Tutorials

Cover Image for Building an AI Writer SaaS with Next.js and Supabase

Building an AI Writer SaaS with Next.js and Supabase

57 min read
Learn how to build an AI Writer SaaS with Next.js and Supabase - from writing SEO optimized blog posts to managing subscriptions and billing.
Cover Image for Announcing the Data Loader SDK for Supabase

Announcing the Data Loader SDK for Supabase

8 min read
We're excited to announce the Data Loader SDK for Supabase. It's a declarative, type-safe set of utilities to load data into your Supabase database that you can use in your Next.js or Remix apps.
Cover Image for Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

Adding AI capabilities to your Next.js SaaS with Supabase and HuggingFace

20 min read
In this tutorial, we will learn how to use add AI capabilities to your SaaS using Supabase Vector, HuggingFace models and Next.js Server Components.
Cover Image for Building an AI-powered Blog with Next.js and WordPress

Building an AI-powered Blog with Next.js and WordPress

17 min read
Learn how to build a blog with Next.js 13 and WordPress and how to leverage AI to generate content.
Cover Image for Using Supabase Vault to store secrets

Using Supabase Vault to store secrets

6 min read
Supabase Vault is a Postgres extension that allows you to store secrets in your database. This is a great way to store API keys, tokens, and other sensitive information. In this tutorial, we'll use Supabase Vault to store our API keys
Cover Image for Introduction to Next.js Server Actions

Introduction to Next.js Server Actions

9 min read
Next.js Server Actions are a new feature introduced in Next.js 13 that allows you to run server code without having to create an API endpoint. In this article, we'll learn how to use them.