React started out as a simple library for building user interfaces. With React 19, the ecosystem has matured significantly with Server Components, the React Compiler, and improved TypeScript integration.
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.
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 later 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',} 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.
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:
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.
Refs as Props in React 19
In React 19, forwardRef is deprecated. Instead, you can pass ref as a regular prop:
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:
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 three main benefits to this approach:
- It's easier to import the component, since you only need to import one component
- It's easier to understand the component, since it's all in one place
- 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.
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 approach significantly improves performance by reducing the JavaScript sent to the browser.
Server Components for Data Fetching
Server Components can directly fetch data without hooks or effects:
// 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:
'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:
// page.tsx - Server Component that fetches initial dataasync function OrganizationDashboard({ organizationId }: { organizationId: string }) { const initialData = await fetchOrganization(organizationId); return ( <Suspense fallback={<Loading />}> <OrganizationView initialData={initialData} /> </Suspense> );}// 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:
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:
'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. I highly recommend reading it.
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:
// With React Compiler, this is automatically optimizedfunction 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:
- Effect dependencies: When a memoized value is used as an effect dependency and you need precise control over when it changes
- External library requirements: When integrating with libraries that require stable references
- Debugging: When you need to explicitly control memoization for debugging purposes
// Explicit memoization when needed for effect dependenciesfunction 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:
- Use a single
useStatehook with an object - Use the
useReducerhook, which is a more powerful alternative touseStatethat allows you to manage more complex state using reducers - 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 best practices for writing clean React 19 code, including:
- TypeScript fundamentals: Avoiding
any, using strong typing for props, and leveraging refs as props - Component patterns: Server Components for data fetching, Client Components for interactivity, and namespaced components for organization
- Modern hooks: Custom hooks for encapsulation, reduced reliance on
useEffectfor data fetching, and letting the React Compiler handle memoization
The React ecosystem has evolved significantly with Server Components and the React Compiler. Embrace these new patterns for better performance and cleaner code.
I hope this article was helpful. If you have any questions, feel free to reach out!
Want to see these patterns in action? Check out the our React SaaS Boilerplate to build your SaaS.