# Writing clean React

> Level up your React 19 coding skills with TypeScript. Learn modern patterns including Server Components and the React Compiler.

*Published: 2023-01-12*
*Canonical: https://makerkit.dev/blog/tutorials/clean-react*

---

React started out as a simple library for building user interfaces. With React 19, the ecosystem has grown up: Server Components, the React Compiler, and better TypeScript support ship out of the box now.

In this guide, we'll go over best practices for writing clean React code using TypeScript. These practices are widely used across all the Makerkit SaaS products and reflect modern React 19 patterns.

## Use TypeScript Well

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. 

I know, you're prototyping quickly. You'll fix it later. You won't. Just use `unknown` from the start.

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:

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

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

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

const LOADING: Status = 'loading';
```

Or simply an object:

```tsx
const STATUS = {
  Loading: 'loading',
  Error: 'error',
  Success: 'success',
} as const;
```

### 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.

```tsx
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. 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`:

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

So we can rewrite the above example as:

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

That makes it more readable and easier to use.

#### Refs as Props in React 19

In React 19, `forwardRef` is deprecated. Instead, you can pass `ref` as a regular prop:

```tsx
type ButtonProps = {
  id: string;
  ref?: React.Ref<HTMLButtonElement>;
};

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

This simplifies component APIs and makes refs work like any other prop.

#### 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:

```tsx
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.

```tsx
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 three 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`:

```tsx
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:

```tsx
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.

### Server Components vs Client Components

With React 19 and frameworks like Next.js, the recommended pattern is to use Server Components for data fetching and Client Components for interactivity. This cuts the JavaScript you ship to the browser, often by half or more.

#### Server Components for Data Fetching

Server Components can directly fetch data without hooks or effects:

```tsx
// This is a Server Component (default in Next.js App Router)
async function OrganizationPage({
  params
}: {
  params: { organizationId: string }
}) {
  const organization = await fetchOrganization(params.organizationId);

  return <OrganizationCard data={organization} />;
}
```

Server Components fetch data on the server, render HTML, and send zero JavaScript for those parts to the client. They're ideal for content-heavy sections like dashboards, product lists, or profile pages.

#### Client Components for Interactivity

Use the `'use client'` directive when you need interactivity, hooks, or browser APIs:

```tsx
'use client';

function OrganizationActions({ organizationId }: { organizationId: string }) {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <button onClick={() => setIsEditing(true)}>
      Edit Organization
    </button>
  );
}
```

#### The Hybrid Pattern

A common pattern is combining Server Components for initial data loading with client-side libraries like TanStack Query for mutations and real-time updates:

```tsx
// page.tsx - Server Component that fetches initial data
async function OrganizationDashboard({ organizationId }: { organizationId: string }) {
  const initialData = await fetchOrganization(organizationId);

  return (
    <Suspense fallback={<Loading />}>
      <OrganizationView initialData={initialData} />
    </Suspense>
  );
}
```

```tsx
// organization-view.tsx - Client Component for interactivity
'use client';

import { useQuery } from '@tanstack/react-query';

function OrganizationView({ initialData }: { initialData: Organization }) {
  const { data } = useQuery({
    queryKey: ['organization', initialData.id],
    queryFn: () => fetchOrganization(initialData.id),
    initialData,
  });

  return <OrganizationCard data={data} />;
}
```

This pattern gives you the best of both worlds: fast initial loads from Server Components and interactive updates from Client Components.

## 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 and cache data within a component. We can encapsulate this logic in a custom hook:

```tsx
export function useOrganization(organizationId: string) {
  return useQuery({
    queryKey: ['organization', organizationId],
    queryFn: () => fetchOrganization(organizationId),
  });
}
```

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

```tsx
'use client';

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

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

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

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

Note: In React 19 with Server Components, prefer fetching data in Server Components for initial loads. Use client-side hooks like TanStack Query for mutations, real-time updates, or when you need client-controlled data flows.

### 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.

With React 19 and Server Components, data fetching should primarily happen in Server Components or using data-fetching libraries like TanStack Query. The `useEffect` hook should be reserved for its original purpose: real side effects like subscriptions, timers, or DOM integrations.

[The React documentation has an in-depth guide on when to use the "useEffect" hook, and when not to use it](https://react.dev/learn/you-might-not-need-an-effect). Read it. Seriously.

When you do need `useEffect`, ask yourself: "Can this data be fetched in a Server Component or consumed via a data-fetching library?" Only fall back to `useEffect` when you genuinely need a client-only API or a highly interactive, client-controlled flow.

### React Compiler and Automatic Memoization

React 19 introduced the React Compiler, which automatically optimizes your components by memoizing values, functions, and components at build time. With the React Compiler enabled (supported in recent versions of Next.js and Expo), you no longer need to manually use `useCallback`, `useMemo`, or `React.memo` for performance optimization.

The compiler analyzes your code and automatically:
- Skips unnecessary re-renders
- Memoizes expensive calculations
- Stabilizes function references

This means cleaner code without the mental overhead of manual memoization:

```tsx
// With React Compiler, this is automatically optimized
function Parent() {
  const success = () => {
    // do something
  };

  const config = { foo: 'bar' };

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

### When to Still Use useCallback and useMemo

While the React Compiler handles most cases, `useCallback` and `useMemo` remain useful as escape hatches when you need explicit control:

1. **Effect dependencies**: When a memoized value is used as an effect dependency and you need precise control over when it changes
2. **External library requirements**: When integrating with libraries that require stable references
3. **Debugging**: When you need to explicitly control memoization for debugging purposes

```tsx
// Explicit memoization when needed for effect dependencies
function Parent() {
  const success = useCallback(() => {
    // do something
  }, []);

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

function Child({ onSuccess }: { onSuccess: () => void }) {
  useEffect(() => {
    // This effect depends on onSuccess
    onSuccess();
  }, [onSuccess]);

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

For new code, rely on the compiler for memoization and use `useCallback`/`useMemo` only where you need explicit control.

### 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](https://github.com/pmndrs/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.

## Wrapping Up

React 19 ships real improvements: Server Components for faster loads, the Compiler for automatic memoization, and refs as regular props. Use them. Your bundle size and your future self will thank you.

Questions? Feel free to reach out.

---

Want to see these patterns in action? Check out the [our React SaaS Boilerplate](/react-saas-boilerplate) to build your SaaS.
