Building Scalable and Reusable Components In React
How to build large interactive applications in React without going crazy.
09 Oct 2024
Introduction
React is an extremely powerful and flexible UI library. Due to this flexibility however, it often becomes increasingly difficult to maintain and debug React applications as they grow larger. Without discipline in how state is managed as an application grows, state becomes untraceable, user interaction events get handled in multiple unexpected places, and state changes cause unintended effects at a distance.
In this article, I assume that you have a basic understanding of TypeScript and React. I go over the core principles of React, the difficulties when applying best practices to larger applications, and the common mistakes made when doing so. At the end, I offer some suggestions on how to scale up best practices and handle complexity when building larger interactive React applications.
Segregating view from state
React is a component-based UI library where UI is broken down into reusable components that encapsulate view and state logic. It is built upon a one-way data flow principle which states that data should only flow downwards to child components, and only events should propagate upwards to parent components.
A good practice in React is to write components where UI is driven purely by state passed in from parents through props, and actions are handled by emitting events with no side effects through callback props. These components are often called pure components.
The major advantage of this practice is the decoupling of a component’s presentation and interactivity from its state management.
interface ProfileViewProps {
username: string;
onUsernameChange?(name: string): void;
}
function ProfileView({
name,
onUsernameChange,
}: ProfileViewProps) { /* snip */ }
This allows us to drive a pure component in any way e.g. via in-memory state with useState
, custom state functions, and more.
function App() {
const [name, setName] = useState("Ada");
return (
<ProfileView
username={name}
onUsernameChange={setName}
/>
);
}
function PersistedApp() {
const name = useStorage(/* snip */);
const updateName = useStorageMutation(/* snip */);
return (
<ProfileView
username={name}
onUsernameChange={updateName}
/>
);
}
Effectively, pure components are driven by data props and events, enabling a clear segregation between view and state.
Some frontend developers might be reminded of the Model-View-Controller (MVC) or Model-View-ViewModel (MVVM) patterns. Pure components are essentially the view in MVC or MVVM.
Scaling issues with pure components
The benefits of pure components are obvious and easy to demonstrate on simple components i.e. when the number of primitive values in a component’s state and events can be counted in one hand.
I specify counting the number of primitive values instead of number of props as it is more representative of a component’s complexity; we could easily group all primitive values for a component into an object under a single prop but this would not change its inherent complexity.
However from my experience in building larger interactive React applications, it becomes more challenging to implement pure components in a managable way as application complexity grows.
Errors, asynchronous data fetching, and asynchronous mutations increase the number of props required
Any sufficiently mature application has to handle errors, data-fetching loading states, and asynchronous-mutation loading states. While it is possible to handle these states outside the pure component i.e. higher up the component tree, it is much better practice to colocate the representation of different states for a given view. This makes the UI more reproducible, refactorable, and testable.
An implementation of a component that handles all states might look like this.
interface ProfileViewProps {
username: string | null;
isUsernameLoading?: boolean;
onUsernameChange?(name: string): void;
isUsernameChanging?: boolean;
usernameError?: string;
}
function ProfileView({
username,
isUsernameLoading = false,
onUsernameChange,
isUsernameChanging = false,
usernameError,
}: ProfileViewProps) {
return (
<div>
{isUsernameLoading ? (
<Skeleton />
) : (
<div>
<TextLineEdit
value={username}
onValueChange={onUsernameChange}
/>
{isUsernameChanging && <Spinner />}
</div>
)}
{usernameError && <p>{usernameError}</p>}
</div>
);
}
In TypeScript, we can use a discriminated union to indicate that username
is non-nullable if and only if isUsernameLoading
is false
, but that is beyond the scope of this article.
However when writing a pure component, this effectively means that a single state value for a component requires multiple props in the prop definition. As a result, both the component definition and its usage require more lines of code, which results in more lines to read, maintain, and potentially make mistakes in.
I like to call this props explosion.
Composing views also compose their state and event props
To exacerbate the props explosion issue, components are often a composition of other components. Because a pure component does not have any meaningful local state in its definition, it must pass data to its child components from its own prop definition. Effectively, the prop definition of a pure component includes the aggregation of the prop definitions of its child components.
As components grow in size and compose more child components together, the number of props required can extend into the hundreds.
interface SettingsViewProps extends
ProfileViewProps,
NotificationViewProps,
PrivacyViewProps {}
// resolves to
{
username: string | null;
isUsernameLoading?: boolean;
onUsernameChange?(name: string): void;
isUsernameChanging?: boolean;
usernameError?: string;
/* snip */
allowNotifications: boolean | null;
isAllowNotificationsLoading?: boolean;
onAllowNotificationsChange?(name: string): void;
isAllowNotificationsChanging?: boolean;
allowNotificationsError?: string;
/* snip */
acceptedTerms: boolean | null;
isAcceptedTermsLoading?: boolean;
onAcceptedTerms?(): void;
isAcceptingTerms?: boolean;
acceptedTermsError?: string;
/* snip */
}
Common mistakes
As a result of this friction, some common strategies to manage this complexity are to either:
- remove error and asynchronous loading states from a component definition,
- use asynchronous events to define potentially asynchronous mutation events and handle state management internally, or
- define a single resource identifier prop on a component and handle state management for that resource internally.
In my experience, these are mistakes that cause more harm than they solve.
Removing error and loading states from the component definition
To reduce the number of props specified in a prop definition, we might remove all error and asynchronous loading states from the definition of a component. However by doing so, we are obscuring the fact that these states intrinsically exist.
If these states are ignored completely and not shown visually, it results in a poor user experience as the user does not receive any visual feedback when these states occur e.g. a text field that appears editable while initial data is still loading, causing user input to be overwritten when the initial data finally populates.
If these states are handled external to the component, it increases the likelihood of visual and behavioural inconsistency. Each call site might slightly differ in their implementations of loading and error states, which is made worse when refactoring or updating the component.
For this reason, I’m also wary of React <Suspense />
. While it is useful in some scenarios, I see a worrying trend of Suspense boundaries being used to replace thoughtful loading state views with generic spinners.
To guarantee the best possible user experience and consistency in the UI, define error and loading states on your components and handle them internally.
Using asynchronous events for asynchronous mutation events
To avoid specifying an additional isMutating
prop for every onMutate
event, we might attempt to define the event as asynchronous with a Promise<void>
return type instead of void
.
interface UsernameEditorProps {
username: string;
onUsernameChange?(name: string): Promise<void>;
}
The idea behind this approach is to await the event when it’s emitted and handle the loading state internally.
function UsernameEditor({
username,
onUsernameChange,
}: UsernameEditorProps) {
const [isUsernameChanging, setIsUsernameChanging] = useState(false);
return (
<div>
<input
value={username}
onChange={async (ev) => {
setIsUsernameChanging(true);
await onUsernameChange?.(ev.target.value);
setIsUsernameChanging(false);
}}
/>
{isUsernameChanging && <Spinner />}
</div>
);
}
If the component is used at a site where the change handler is asynchronous, the loading state will be shown internally.
function App() {
const userData = useQuery(/* snip */);
const updateUsername = useMutation(/* snip */);
return (
<UsernameEditor
username={userData.username}
onUsernameChange={async (name) => {
await updateUsername.mutate(name);
}}
/>
);
}
While this simplifies the component definition, it also introduces a major issue when using React. Recall that React is built upon the one-way data flow principle. When an event is propagated up, it must either be explicitly handled or do nothing; it should not have any implicit side effects.
However, asynchronous events used in this matter break the one-way data flow principle by creating an implicit effect between the event’s asynchronous resolution and the state of the component that emitted the event. This results in inconsistent state and hard-to-debug race conditions in the UI.
For example, if two onUsernameChange
events are emitted, and one resolves before the other, the <UsernameEditor />
component will call setIsUsernameChanging(false)
and exit its loading state before the second event resolves. This results in incorrect UI where the view does not show a loading state even though the second updateUsername.mutate
call has not yet completed.
To avoid these UI bugs, stick to using pure components that receive loading props.
Using a resource identifier and handling loading states internally
To avoid explicitly specifying all data values and events for a component definition, we might attempt to associate the required data for a component to a specific resource identifier. Then, the component only has to receive this resource identifier as a prop to fetch its view data from a store and mutate the store appropriately.
interface ProfileViewProps {
userId: string;
}
function ProfileView({
userId,
}: ProfileViewProps) {
const userData = useQuery(userId, /* snip */);
const updateUsername = useMutation(userId, /* snip */);
return (
<div>
{userData.isLoading ? (
<Skeleton />
) : (
<div>
<TextLineEdit
value={userData.data.username}
onValueChange={updateUsername.mutate}
/>
{updateUsername.isLoading && <Spinner />}
</div>
)}
{updateUsername.error && <p>{updateUsername.error}</p>}
</div>
);
}
As far as I can tell, this approach was popularized by libraries such as React Query and Apollo Client, but it applies to any library that allows for data to be fetched and modified based on some resource identifier.
The main benefit of this approach is that all loading, mutation, and error states are wrapped up nicely inside the component under the resource identifier, so there is no props explosion.
Secondly, the single resource identifier can be used to fetch different data depending on the state required by a component. This allows child components to be composed together without exposing all props required by each child component to the prop definition of the parent.
interface SettingsViewProps {
userId: string;
}
function SettingsView({
userId,
}: SettingsViewProps) {
return (
<div>
<ProfileView userId={userId} />
<NotificationView userId={userId} />
<PrivacyView userId={userId} />
</div>
);
}
While this simplifies the component’s prop definition and usage, it often adds more issues and complications than it resolves.
High coupling of views to state management libraries
The main issue with this approach is that the component must be highly coupled to a specific state management library such that its implementation and usage adheres to the restrictions of that library. As a result, it is not possible to customize the way in which data or event handlers are passed into a specific view.
Difficulty in mocking data and testing views and interactions
As a continuation of the previous point, it is no longer trivial to pass mock data or capture interaction events for the purposes of building component storybooks or tests. Data and event handlers must be passed into the component via complicated mocking systems provided by the state management library used.
In my experience, these mocking systems are often overly complicated and fundamentally difficult to use. This is unavoidable because the data rendered into the view is given by the state management library and will always be fundamentally disconnected from the props of the component.
Inability to reuse views
As the implementation of data fetching and mutation is coupled to the view within the component, the component cannot be reused for other similarly-shaped resources or in other parts of an application.
Take for example a PostTagEditor
component that allows adding, renaming, and removing tags from a post with the ability to select from existing tags or create new ones. We might use a postId
prop to fetch the existing tags on a post, fetch all other tags available to the user via context, and handle all mutations internally.
interface PostTagEditorProps {
postId: string;
}
function PostTagEditor({
postId,
}: PostTagEditorProps) {
const existingTags = useQuery(postId, GetTagsOnPost);
const userInfo = useContext(UserInfoContext);
const allTags = useQuery(userInfo.id, GetUserTags);
const attachTagToPost = useMutation(AttachTagToPost);
const detachTagFromPost = useMutation(DetachTagFromPost);
const editTag = useMutation(EditTag);
const createNewTag = useMutation(CreateNewTag);
/* snip */
}
If we later need to use this same view for a new FileTagEditor
, we would either have to duplicate all the view code in PostTagEditor
or refactor the whole thing to segregate its views from its state logic by defining a pure component.
Overly narrow scoping of state
When error and asynchronous loading states are stored within a child component further down the component tree, it prevents that state from being used in other parts of the UI.
While it is often desirable in React to keep the scope of state small for performance reasons, it should be an opt-in design choice and not the default. In my experience, loading states are more often than not simultaneously local and global.
Take for example a settings page that has multiple subsections, each with hundreds of editable fields that should automatically save changes to a database. The initial approach might be to build each subsection as its own contained component with error and asynchronous loading states handled internally. However, this locks us into a design where it is surprisingly tedious to add an additional saving indicator on the page header or show page-level errors.
Building pure components scalably
Now that we understand the difficulty in building complex pure components, I’ll go over some useful patterns that mitigate this complexity while avoiding the common mistakes discussed previously.
These patterns are not hard-set rules but rather guidelines. Some patterns are more applicable than others, and some are only useful in specific scenarios. Regardless, they should all be considered thoughtfully against the context in which they might be used. These patterns are merely the ones that I’ve found the most success with.
Define mutation props separately from other data props
Identify all possible mutation events for a component and any required loading states, then group them into a separate interface.
interface TagEditorMutationEvents {
onAttachTag(tagId: string): void;
isAttachingTag: boolean;
attachTagError: string | null;
onDetachTag(tagId: string): void;
isDetachingTag: boolean;
detachTagError: string | null;
onEditTag(tagId: string, update: Partial<Tag>): void;
isEditingTag: boolean;
editTagError: string | null;
onCreateTag(tag: Tag): void;
isCreatingTag: boolean;
createTagError: string | null;
}
When defining the component, we can extend its prop definition with the mutation events interface.
interface TagEditorProps extends Partial<TagEditorMutationEvents> {
existingTags: Tag[];
allTags: Tag[];
}
function TagEditor({
existingTags,
allTags,
onAttachTag,
isAttachingTag = false,
attachTagError = null,
/* snip */
}: TagEditorProps) { /* snip */ }
I extend a Partial
of the events interface as I believe that component event handlers should always be optional. This is a personal choice that I might write more about in the future, but that is beyond the scope of this article.
The separate mutation events interface acts as the abstraction between the component and the implementation of its mutation handlers.
We could take this further to separate mutation events into more granular groups of interfaces. However in practice, I have not yet seen much benefit in separating them further as there are better alternatives to grouping events discussed later.
Extract mutation handlers into custom hooks
After extracting a component’s mutation event definitions into a separate interface, we can also extract the implementation of their handlers into custom hooks instead of writing it inline within the component.
Firstly, this avoids mixing data-mutation logic with view-related logic in the definition of a component. In my experience, React components tend to suffer from code bloat because it is easy to dump everything into the component definition, ranging from state management and data-fetching logic to complex view-related details and side effects. By separating mutation logic into custom hooks, we separate these concerns and make the component easier to read and maintain.
Secondly, by defining the implementation of mutation handlers in custom hooks, we can more flexibly compose and reuse them in other parts of the application. This allows us to use pure components more easily by passing mutation handlers down from parent components without unnecessary code repetition.
Lastly, the custom hook acts as the opaque implementation of the mutation events interface. We can define multiple implementations of the interface without changing the pure component, thereby decoupling our view from our data-mutation logic entirely.
function usePostTagEditorMutators(
postId: string,
): TagEditorMutationEvents { /* snip */ }
function useFileTagEditorMutators(
fileId: string,
): TagEditorMutationEvents { /* snip */ }
function App() {
const postTagMutators = usePostTagEditorMutators(postId);
const fileTagMutators = useFileTagEditorMutators(fileId);
return (
<div>
<p>Edit post</p>
<TagEditor {...postTagMutators} />
{/* snip */}
<p>Edit file</p>
<TagEditor {...fileTagMutators} />
</div>
);
}
Create separate view-only components and controller components
Although pure components are extremely powerful, a controller is eventually required in the component tree to drive them. In React, a controller is simply a component that is stateful. This might include storing local state, performing data fetching, or defining mutation handlers.
We also cannot indefinitely compose only pure components. As discussed previously, the prop definition of a pure component includes all the props required by child components. If we were to build an entire application with pure components alone, the number of props for a component would grow to an impossible size as we traverse up the component tree. React would also perform poorly by default due to excessive re-rendering caused by state being updated high up in the component tree.
Pure components are also difficult to use when a view might be dynamically rendered, either due to conditional rendering, dynamic state, or as part of a dynamic list of content. In my experience, modern applications require a lot of dynamic rendering due to UI elements like popovers, virtualized lists, modals, and more. We cannot simply fetch and load all data that might be required for every permutation of every dynamic view in an application.
What we want is a way to decide on a per-usage basis between when a component should be pure and when a component should act as a controller to handle its own state internally. This might sound obvious but is hard to implement correctly.
I’ve seen many codebases choose to avoid this problem entirely by making every component a controller instead. This requires a complicated state management library to load data on a per-component basis, and complicated mechanisms for fetch deduplication, change-detection, and application-wide reactivity.
While I do agree with using a state management library to handle application-wide reactivity, I’ve found that the simpler approach to building React applications is to simply define two components per view: a pure component that is often named with a View
suffix, and one or more controller components that use the pure component with additional state controller logic.
An example of the interface definitions used for such a view might look like this.
/* View */
export interface ProfileViewProps extends
ProfileViewData,
Partial<ProfileViewMutationEvents>,
ProfileViewStyleProps {}
export type ProfileViewData = {
isLoading: true;
username: undefined;
} | {
isLoading: false;
username: string;
};
export interface ProfileViewMutationEvents {
onUsernameChange(name: string): void;
isUsernameChanging: boolean;
usernameError?: string;
}
export interface ProfileViewStyleProps {
size?: "xs" | "sm" | "base";
className?: string;
style?: CSSProperties;
}
export function ProfileView({
isLoading,
username,
onUsernameChange,
isUsernameChanging = false,
usernameError,
...styleProps
}: ProfileViewProps) { /* snip */ }
/* Controller */
export interface UserProfileControllerProps extends
ProfileViewStyleProps {
userId: string;
}
export function UserProfileController({
userId,
...styleProps
}: UserProfileControllerProps) {
const data = useUserProfileViewData(userId);
const mutators = useUserProfileViewMutators(userId);
return (
<ProfileView {...data} {...mutators} {...styleProps} />
);
}
export function useUserProfileViewData(
userId: string,
): ProfileViewData { /* snip */ }
export function useUserProfileViewMutators(
userId: string,
): ProfileViewMutationEvents { /* snip */ }
While it is a lot of code simply for the interface definitions of a view, in my experience it results in much better organization of code, higher code readability, and a massive increase in code reuse.
Use slot composition
If a parent component does not need to control the state of its child components or respond to events emitted by the child components, consider using slot composition.
Slot composition is a UI pattern where a component defines “slots” into which child content should be inserted. In React, the default slot is given the special children
name and receives any content passed as children in the JSX template. Additionally, we can define multiple slots for a component.
interface SettingsViewProps {
profileView: ReactNode;
notificationView: ReactNode;
}
function SettingsView({
profileView,
notificationView,
}: SettingsViewProps) {
const [showMore, setShowMore] = useState(false);
return (
<div>
{profileView}
{showMore && (
<div>
{notificationView}
</div>
)}
</div>
);
}
When using slot composition, the data props and event handlers for slotted components do not need to be forwarded through the parent component’s prop definition, avoiding the accumulation of props on parent components. It also allows for a natural grouping of data props and event handlers as they are passed into the child components directly.
function App() {
/* snip */
return (
<SettingsView
profileView={
<ProfileView {...profileProps} />
}
notificationView={
<NotificationView {...notificationProps} />
}
/>
);
}
Furthermore, slots enable better composition of components because we can replace the implementation of any component with another. For example, if we don’t need notificationView
to be a pure component in a specific situation, we can easily swap its component out with a controller component.
function App() {
/* snip */
return (
<SettingsView
profileView={
<ProfileView {...profileProps} />
}
notificationView={
<NotificationController />
}
/>
);
}
There are more benefits to slot composition in React related to performance optimizations, but that is outside the scope of this article.
Conclusion
In this article, we discussed the challenges of managing complexity when scaling up interactive React applications, as well as the common mistakes made and better alternatives for managing that complexity. I learnt these lessons the hard way through my experience as a software developer for Charter Space.
I hope that my takeaways will help you more easily identify the same mistakes I made, and help you write better software moving forward. If you have suggestions for improvement or disagree with any of the practices I recommend, I encourage you to share your thoughts with me.