
Forward-thinking architecture in React
React is currently one of the most popular frontend web frameworks, and there’s a good reason for that: it’s able to express common UI logic patterns in simple ways, and it’s very easy to build your application modularly.
But building a clean and modular codebase won’t happen just because you decided to use React. It’s something you have to consciously choose. React provides a lot of ways to make development easier. However, when done poorly, an ugly React codebase is not much better than an ugly vue/angular/jquery/etc codebase. But just like those other libraries, there are common patterns you can stick to that will help.
React has a lot of features and niches. I won’t be covering all of them here. Instead, this is just a high level overview of how I personally try to structure my React projects to avoid a lot of refactoring down the road.
Disclaimer
Ultimately, this article is my subjective opinion on how to build a cleaner React codebase. There are obviously many ways to accomplish the same things. But I tend to stick to these patterns because I’ve found that they help reduce my cognitive load and make it easier for new people to understand the code. It's surprisingly easy to over-architect yourself into a corner, spending more time building abstractions than building features. The goal is to find the balance that works for your project.
The Foundation
When building any application, there are a lot of common practices you can adhere to that will help keep the codebase easier to manage over time. While some of these practices aren’t necessarily specific to React, they can still help make your components clean and readable in the long term.
Treat your app as an “implementation” of your own SDKs
Think about what the “core functionality” of your app is. Is it communicating with an API or via Bluetooth? Is it keeping track of some local data in a structured way? Is it performing some complex computations? It’s important to first identify these pieces and how they fit together. Most of the time, each of these core pieces can be built separately with zero awareness of each other or any UI. This also conveniently gives you the ability to reuse parts of your app in other projects if you want.
Avoid unnecessary coupling
If a piece of functionality doesn't need to be aware of another part of your app, write it independently. This principle sounds obvious, but it's easy to violate when you're moving quickly. Every unnecessary dependency you create makes your code harder to test, harder to modify, and harder to reuse.
When using third party specialized libraries, it can be useful to wrap the parts of the library that you’re using in helper methods. This way, if you ever need to change libraries, or change how something works, you can easily change the helper method itself, instead of everywhere the method would be called. However, it is very easy to go overboard with this. Remember that you’re doing this so that you’ll be able to change multiple things in one spot, instead of having to change multiple things in multiple spots. Only create abstractions to the extent that they're actually helpful for the future you can reasonably foresee.
Create an “API Client” class for your API
A really ugly pattern I’ve seen in many projects is to put raw “fetch” or “axios” calls to your API directly within a React component. Not only does this make your component more verbose, it also scatters the core functionality of your app all over the project, and encourages inconsistent patterns when making API calls.
To mitigate this, I usually create a single class that represents a client for my API. This class will have methods on it that represent all the possible API calls that your app may make. The class will also have a generic method to send an http request to the API (I usually call this something like sendAPIRequest). All the other API call methods will invoke this shared method. This reduces code duplication and makes it so that all the API calls can work in the exact same way at their core.
The following is an example of our API client class. We have made the assumption that the request and response body is always either JSON or empty. We’ve also made the assumption that we’ll be using a Bearer token for authorization. Obviously this can be tweaked according to the specifics of your application:
src/services/api/client.ts
import qs from 'querystring';
import type { UserStatistics } from './types';
// This is an example api client based on a hypothetical "sports fantasy" app
type SportsFantasyHttpRequestOptions = {
query?: { [key: string]: any } | null | undefined,
auth?: boolean,
headers?: { [key: string]: string },
body?: any | undefined,
abortSignal?: AbortSignal,
expectResponse?: boolean,
};
// Options that can be specified for all API calls
export type SportsFantasyAPICallOptions = {
abortSignal?: AbortSignal,
};
// An error class that can be thrown when an http request returns with a non-2xx status code
export class SportsFantasyAPIError extends Error {
url: string;
httpStatusCode: number;
constructor(
// a user-readable error message
message: string,
// the url that the request was sent to
url: string,
// the http status code of the error
httpStatusCode: number,
) {
super(message);
this.url = url;
this.httpStatusCode = httpStatusCode;
}
}
// A client class for communicating with the SportsFantasy API
export class SportsFantasyAPIClient {
baseUrl: string = "https://examplesportsfantasyapp.biz/api";
accessToken: string | null = null;
// Handles sending an HTTP request to the API
private async sendAPIRequest<TResponse>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: string,
options?: SportsFantasyHttpRequestOptions
): Promise<TResponse> {
// build the full url
let url = `${this.baseUrl}/${endpoint}`;
// add query parameters (if any)
const query = options?.query;
if(query) {
// remove any "undefined" values
for(const key of Object.keys(query)) {
if(query[key] === undefined) {
delete query[key];
}
}
// serialize the query string and append it to the url
const queryString = qs.stringify(query);
if(queryString.length > 0) {
url += '?' + queryString;
}
}
// add authorization headers if needed
const headers = {...options?.headers};
if((options?.auth ?? true) && this.accessToken && headers['authorization'] == null) {
headers['authorization'] = `Bearer ${this.accessToken}`;
}
// transform your body to json, and add the content-type header, if needed
let body = options?.body;
if(body !== undefined && headers['content-type'] == null) {
body = JSON.stringify(body);
headers['content-type'] = 'application/json';
}
// send the request
const res = await fetch(url, {
method,
headers,
body,
signal: options?.abortSignal
});
if(!res.ok) {
// the request returned with a non-2xx status code, so cancel and throw an error
// TODO alternatively, you may also choose to instead parse error information from the response body if your API provides it
res.body?.cancel();
throw new SportsFantasyAPIError(res.statusText, url, res.status);
}
// if the response has no body, just return undefined/void
const expectResponse = options?.expectResponse ?? (res.body != null);
if(!expectResponse) {
res.body?.cancel();
return undefined!;
}
// parse the response json
return (await res.json()) as TResponse;
}
// Fetches statistics for the logged-in user
async getMyStatistics(params: {
startDate?: Date,
endDate?: Date,
}, options?: SportsFantasyAPICallOptions) {
return await this.sendAPIRequest<UserStatistics>('GET', 'v1/me/statistics', {
...options,
query: {
startDate: params?.startDate?.toISOString(),
endDate: params?.endDate?.toISOString()
},
});
}
// TODO add more api calls here
}While this may seem verbose at a glance, it would be much more verbose to repeat this logic in multiple places within your app. You may also choose to simplify parts of this by using axios or any other http request library of your choice (although personally I think “fetch” is pretty sufficient and flexible for most cases).
Benefits:
- Your IDE will be able to track all the places where a specific API call is being made within your project
- The Typescript transpiler will warn you if you pass the wrong request arguments to any API call method
- Implementations for each API call can be as small as possible, since we’re delegating all the repetitive logic to the sendAPIRequest method
- There is a common SportsFantasyAPIError class, so you can easily handle error responses if needed
You can even export a singleton instance of your client class to use within the rest of the app, if it’s unlikely that you’ll ever need multiple API client instances:
src/services/api/index.ts
import { SportsFantasyAPIClient } from './client';
export * from './types';
const sfApiClient = new SportsFantasyAPIClient();
export default sfApiClient;Build in layers
Well-structured applications are built in distinct layers, each with clear responsibilities. While there are many ways you could divide up these layers, these are the most common ways I end up drawing these boundaries:
The Core Functionality Layer handles sending and fetching data, performing general operations, and contains your business logic. This layer should ideally have zero (or very little) awareness of your UI. If you can take this layer and drop it into a command-line tool, a different UI framework, or a server-side process, you've done it right.
The State Layer manages the global state of your app (not the state of individual components), and handles saving and loading state from the disk. This means things like React contexts, redux state, AsyncStorage, etc. Think of this as the layer that knows "the user has team #3 selected" but doesn't care what the UI chooses to do with that information.
The Presentation Layer is your UI. It calls into your core functionality and state layers to accomplish its goals. Components at this level should be relatively thin, delegating the heavy lifting to lower layers.
Even within a single layer, you should organize code by responsibility and avoid mixing concerns. For example, imagine you need to fetch data from multiple API endpoints, aggregate the results, perform some calculations, and then cache the outcome. Don't put all of this logic directly in your API client class just because it involves API calls.
Instead, separate the concerns:
- Your API client class should only handle the mechanics of making HTTP requests
- A separate service or helper class (like StatisticsService) should orchestrate the API calls, handle the aggregation logic, and manage caching
The service class depends on and invokes the API client, but they remain distinct with clear boundaries. The API client knows nothing about your business logic. It just knows how to talk to your API. The service class knows nothing about HTTP details. It just knows how to use the API client to accomplish higher-level goals.
Reusable components
Before you start building your app, it can be good practice to identify the common elements that will be used in multiple places within the app:
- Page or screen layouts - some of your screens might use the same basic layout. Having a shared “layout” component that your screens are composed of can make building new screens a lot easier
- Components - there may be several UI elements that are common through your app, like buttons, cards, menus, etc
- Colors / styles - is there a color scheme you’re using for most of your site or app? Can you categorize the different places you’re using the same colors?
By figuring these out early, it will be easier to know how your application should be built out as you continue to develop it. Sometimes you won't know which pieces need to be reused until you've built them once or twice. Just like task estimation, you sometimes need to get into the trenches and write the actual code before the patterns become obvious. That's why it's valuable to occasionally spend a day or two cleaning up technical debt and refactoring based on what you've learned.
Keeping it DRY
“Don’t repeat yourself” is usually pretty good advice for making parts of your application reusable (when applied thoughtfully). Just make sure your DRY principles adhere to actual business or UI logic, not just surface-level similarity.
If things seem similar but actually function very differently, don't try to jam them into one-size-fits-all components. You'll end up with a component that takes 15 boolean props and has conditional logic everywhere, which can be worse than duplication.
If things are similar "as a coincidence" but changing one might not necessarily change the other, it may be better to either duplicate or make a wrapper component that calls into a shared component. This gives you the flexibility to diverge later if needed.
Componentization Strategy
Here’s one of my favorite React patterns: Make a very generic version of a component that takes all context-specific details as props. Then make slightly less generic versions for your specific situation that contains your more generic component.
For example, say you have two button types that work similarly, but have some slightly different tweaks or behaviors. Make a base “Button” component for yourself that handles the basic behavior that both button types share. Then make components for each specific button type that are composed of your base button component. This way, you can make changes that affect all buttons in your base “Button” component, but you can make more specific tweaks for each button type in your more-specific button components.
// Generic Button - takes everything as props
function BasicButton({ children, variant, size, onClick, disabled, icon }) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{icon && <Icon name={icon} />}
{children}
</button>
);
}
// Specific variant for your use case
function PrimaryActionButton({ children, onClick, loading }) {
return (
<BasicButton
variant="primary"
size="large"
onClick={onClick}
disabled={loading}
icon={loading ? "spinner" : "check"}
>
{children}
</BasicButton>
);
}
// Other variant for a different use case
function MenuActionButton({ children, onClick, loading }) {
return (
<BasicButton
variant="menu"
size="medium"
onClick={onClick}
disabled={loading}
>
{children}
</BasicButton>
);
}One nice part about doing it this way is that it’s easy to change in the long term. Do the two components no longer share as much behavior as they used to? Then you can easily rework their implementations without worrying as much about all the places where the component is used.
Use “slots” for Flexible Components
“Slots” are child parts of a component that can be passed as props. For example, if you have a “layout” component for a screen, you might want it to take “header” and “footer” components as props, so that these can be defined by the caller component. There are two ways to do this:
- Pass a function that returns a React component (ie “renderHeader” or “renderItem”)
This can be useful when you need to pass some arguments to your child render method (ie, renderItem would receive the list item to render as a prop). You’ll need to remember to use “useCallback” when you’re passing the function though, to avoid unnecessary re-renders. - Pass a JSX object directly (ie “header”)
This is a simpler approach, and can be nice in situations where you might just be passing a string in most situations, but still want to be able to pass something more complex in other situations. If you’re passing a JSX though, you’ll need to remember to use “useMemo” to avoid unnecessary re-renders.
Color Schemes and Theming
Name your color constants by purpose, not by appearance. What I mean by this is: Don’t name your color constant “black”, name it “primaryTextColor” or “headerPrimaryTextColor”. This way, you can more easily retheme your app based on categories of colors. In dark mode, your “primaryTextColor” may be white, while in light mode it may be black.
Also, return your scheme from either a hook, or a reusable function that can be turned into a hook later. This allows your app to update all the UI instantly when the theme changes. The nice part is that many React project generators create this by default now.
There are some platform-specific considerations you should make though. In React web, it can be a lot more ideal to just handle most of the color schemes exclusively in CSS, or to create hooks that return a CSS class. This way, all of your styling logic is still kept within the stylesheets. In React native, since CSS isn’t used, it’s better to define hooks that return style objects or colors.
Reusable styles
You might expect this to be the part where I tell you to use “reusable styles” for your project. But actually, this is not usually the way I think about styling in React. While I do usually have a “main” CSS file that defines common CSS variables or classes used through the project, I usually opt to also have one stylesheet per component (or screen). Styles tend to be structured based on page semantics, so by associating styles with your components, you’re already making them reusable, because the components themselves are reusable.
In React web, this ideally means you have at least 1 CSS class per component, which will typically be on the containing element inside the component. Then, you may have some nested styles for the finer parts of the component. If you have a component that’s styled differently depending on what state it’s in, try to conditionally add another CSS class that varies based on the state. For example, you may have the CSS class “dialogue-box”, and then some other CSS class like “expanded” that is only set when the component is in that particular state. Then, in your stylesheet, you can define general styles for “.dialogue-box”, and then more specific styles for “.dialogue-box.expanded”. The component itself would have a boolean prop (or state) called “expanded”, and then append the “expanded” CSS class whenever the “expanded” variable is true:
src/components/DialogueBox/index.tsx
import './style.CSS';
export function DialogueBox(props: {
expanded: boolean,
// TODO other props here...
}) {
const className = "dialogue-box"
+ (props.expanded ? " expanded" : "");
return (
<div className={className}>
{/* TODO the rest of your component */}
</div>
);
}src/components/DialogueBox/style.CSS
.dialogue-box {
/* define your general styles here */
}
.dialogue-box.expanded {
/* define your "expanded" styles here */
}In React native, common practice is to just have the stylesheet in the same file as the component itself. Since styles are objects in React native, it can also be nice to always include a “style” prop on your custom components and pass it down to your inner component’s container view. This way, components that are composed of your other components can still tweak their styles for more specific use cases. You can also take this a step further and have multiple props like “headerStyle” or “footerStyle” for more complex components.
Structuring your components
Any functional component you write should really always be structured into 2 sections: Hooks and state logic at the top, and a JSX tree at the bottom. The reason for this is consistency. A new person looking at your code will always know where to find things. If they’re trying to understand the behavior of the component, they can look at the top where the hooks are. If they’re trying to understand the layout of the component, they can look at the bottom where the JSX is. Avoid mixing JSX into your hooks section, since this can make it more difficult to read.
Hooks
The useEffect hook is super nice for simple effects when props or state change. But as your app gains complexity, you may start to notice that a lot of your useEffect hooks are doing more or less the same things in multiple places. At this point, you should identify the common hook functionality in your app, and simplify some of these calls to use a shared custom hook. For example, if you have a paginated-loading scroll view in several pages of your app, you probably want to write a hook that handles loading paginated data from a source.
Error handling is another important part of your hooks, especially when you’re doing async logic. Rolling this into your own custom hook lets you have consistency with how your app displays the errors to the user.
As the project grows, your custom hooks become building blocks that make implementing features fast and consistent.
Conclusion
Overall, the biggest point of this whole post is really separation of concerns. In other words, each piece of code should have a clear, focused purpose. When you build this way, understanding existing logic becomes a breeze, and adding new features feels less like wrestling with complexity and more like assembling well-fitted pieces.
If you’ve ever worked in React (or any software project) most of these tips may not even be new for you. But intentionally adhering to these patterns can really make a major difference in how approachable and readable your codebase is. If you’re already noticing that your React codebase is getting hairy, try picking a few of these tips and refactoring based on them. You’ll be thankful you did in the long run.

.avif)





