If you're working with React, you've probably noticed that there are numerous ways to organize components' code. This variety comes from a wide range of factors that influence their development, such as project requirements, team experience, personal tastes, and much more.
This article describes in detail my own approach to defining React component files and the rationale for each decision. It covers only function components syntax with TypeScript, but the content is also relevant with plain JavaScript or other type-checkers.
Table of contents:We'll cover a lot of topics so here's an overview:
- Import statements for dependencies;
- Static definitions like constants and types;
- Component definition;
- Variable declarations and hook calls;
- Effects section;
- Rendered content inside the function's
return
statement; - Partial renders as nested functions;
- Local functions as closures, bound to the component;
- Pure functions defined outside the component.
You can also skip to the complete example if you're not interested in the details.
Imports
The top section of a component file consists of the dependencies list, typically ES import statements. There are different categories of dependencies, and I prefer to group and separate them. This helps me have a better overview and easily visualize the component's complexity regarding its dependencies types.
// external imports
import React from "react";
import { useRouter } from "next/router";
// internal imports (reusable components)
import { Button } from "../src/components/button";
// local folder imports (subcomponents)
import { Tag } from "./tag";
import { Subscribe } from "./subscribe";
// styles
import styles from "./article.module.scss";
The first group of imports contains the external or 3rd party dependencies. These are the ones that we'll include in the package.json
file and use absolute path imports from node_modules
.
Long lists of such imports in many components is usually an anti-pattern, signaling that we're polluting our code with too much 3rd party code. A simple solution is to create wrappers around reusable external dependencies, making them easier to change and test.
Internal importsThe second group of imports includes the internal dependencies, usually reusable components or modules that sit outside the component folder. All these imports should use relative import syntax, starting with ../
.
Usually, the majority of imports will reside in this category. Thus we can separate them even further if needed, for example, UI components, data-related imports, services, helpers, etc.
Local importsThe third group of imports incorporates the local dependencies or subcomponents located in the same folder as the component. All the import paths for these dependencies should start with ./
.
Primarily containers or larger components include local dependencies.
Styles importThe last group, which most of the time consists of only a single import, represents the component styles. If there is more than one stylesheet import, it could be a code smell.
Auto-sort imports
I confess that I have an obsession (not compulsive) for manually formatting code, so I have no problem doing this grouping manually. But this doesn't scale within a larger team, so we should automate it.
Luckily, I've recently found a Prettier plugin, namely prettier-plugin-sort-imports from Trivago, that does an excellent job.
I have also customized it to address my sorting preferences by updating the Prettier config file:
module.exports = {
// [...] other Prettier options
importOrder: [
// external dependencies are placed first, by default
// then, include internal dependencies
"^../(.*)",
// then, include local dependencies, except styles
"^./((?!scss).)*$",
// lastly, include everything else
"^./(.*)",
],
importOrderSeparation: true,
};
Static definitions
Below the imports section, we have the file-level constants and the type definitions when using a static type-checker like TypeScript or Flow. Let's go through each of them in detail.
Constants
Any magic value, like a string
or a number
, is placed at the top of the file, below the import statements. Since these are static constants, meaning that their value doesn't change, it makes no sense to place them inside the component because they would be recreated on every re-render.
const MAX_READING_TIME = 10;
const META_TITLE = "Andrei Pfeiffer, Personal Blog";
For more complex static data structures, I prefer to extract them in a separate file, keeping the component file clean.
CONSTANT_CASE
syntax for static constants, whose values we know at compile time.Type definitions
Since I'm using TypeScript, the next thing to declare is the Props
interface shape:
interface Props {
id: number;
name: string;
title: string;
meta: Metadata;
}
- I always use
Props
for the interface name if I don't export it. It helps me instantly identify the type definition for the component's props and distinguish it from the other types, imported or locally defined. - I prepend the component name, like
ButtonProps
, only if I need to export the interface because it shouldn't collide with the localProps
interface when imported in another component. - I avoid the Hungarian notation, like
IProps
orIButtonProps
, because it's not needed. TheProps
name or suffix provides precisely the right amount of information.
Component definition
There are 2 ways we can define function components: using a function declaration or an arrow function. I typically prefer the function declaration, simply because that's what the syntax declares: a function. The official documentation examples use this approach as well.
export function Article(props: Props) {
/**/
}
I've only used the arrow function syntax when I have to use forwardRef
.
export const Article = React.forwardRef<HTMLArticleElement, Props>(
(props, ref) => {
/**/
}
);
Currently, I always use named exports instead of default exports for various reasons:
- I get code completion by default when importing the component, even without TypeScript;
- Imported tokens are automatically updated during renaming refactorings using built-in code editor tooling;
- Everyone working on the project will use the same names for the imports, which provides consistency, unlike default exports which encourage arbitrary names.
// ✅ use named export
export function Article(props: Props) {}
// ❌ avoid default export
export default function Article(props: Props) {}
Variable declarations
Next, we have the variable declarations inside the component. Notice that I call them variables even if I declare them using const
because their value usually changes between renders, even though it's constant through the execution of a single render pass.
const { id, name, title } = props; // A
const router = useRouter(); // B
const initials = getInitials(name); // C
This section usually contains all the variables used at the component level, defined either with const
or let
depending if they change their value or not during rendering:
- Destructured data, usually from props, data stores, or application state;
- Hooks, either custom hooks, framework-specific, or built-in hooks like
useState
,useReducer
,useRef
,useCallback
oruseMemo
; - Processed data used throughout the component, computed by local functions;
Some larger components will definitely have a lot more variables declared in this section. In such cases I tend to group them based on their initialization method.
// framework hooks
const router = useRouter();
// custom hooks
const user = useLoggedUser();
const theme = useTheme();
// destructured data from props
const { id, title, meta, content, onSubscribe, tags } = props;
const { image, author, date } = meta;
// local state
const [email, setEmail] = React.useState("");
const [showMenu, toggleMenu] = React.useState(false);
const [activeTag, dispatch] = React.useReducer(reducer, tags);
// memoized values
const subscribe = React.useCallback(onSubscribe, [id]);
const summary = React.useMemo(() => getSummary(content), [content]);
// refs
const sideMenuRef = useRef<HTMLDivElement>(null);
const subscribeRef = useRef<HTMLButtonElement>(null);
// computed local data
const initials = getInitials(author);
const formattedDate = getDate(date);
The grouping method is very contextual. It depends on the number and types of variables and can differ quite a lot from component to component.
The key takeaway is that related variables should stay together. Adding an empty line between these groups further improves the readability of the code.
Effects
The effects block usually follows the variable declarations section. They are probably the most complex construct in React, but they are pretty straightforward from a syntax point of view.
React.useEffect(() => {
setLogo(theme === "dark" ? "white" : "black");
}, [theme]);
Any identifiers used inside the effect, but defined outside of it, should be included in the dependencies array, even if we're 100% sure their value cannot change.
It's also imperative to remove any event handlers using the cleanup return handler.
React.useEffect(() => {
function onScroll() {
/*...*/
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
Rendered content
The core of a UI component is its content. This content is defined in JSX syntax and rendered as HTML in the browser. That's why I prefer to have the function's return statement as close to the top of the file as possible. Everything else is just details, so they should be placed lower in the file.
export function Article(props: Props) {
// variable declarations
// effects
// ❌ local functions should not be defined before the return statement
function getInitials() {
/*...*/
}
return /* content */;
}
export function Article(props: Props) {
// variable declarations
// effects
return /* content */;
// ✅ local functions defined after the return statement
function getInitials() {
/*...*/
}
}
Shouldn't return be the last statement?
Valid question. The answer is definitely yes for simple, regular functions because we don't usually nest functions in other functions.
However, React components are not simple functions. They usually contain nested functions with various purposes, like event handlers.
From my own experience, having the return
statement at the end, with a bunch of other functions before it, actually impede the reading of the code, making it difficult to find what does the component render:
- We cannot search for the
return
statement as there could be multiple return statements from other nested functions. - Scrolling at the end of the file to find the return statement does not guarantee that we'll easily find it, as the returned JSX block could be pretty large.
I've never encountered a scenario when I wanted to understand the event handlers code first and the content second. It's always been the other way around:
- What is the trigger element?
- What event handler gets called?
- What does the event handler do?
Partial renders
When dealing with large JSX code, it's helpful to extract certain content blocks as separate functions that render parts of our component, similar to how we break large functions into multiple smaller ones.
export function Article(props: Props) {
// ...
return (
<article>
<h1>{props.title}</h1>
{renderBody()}
{renderFooter()}
</article>
);
function renderBody() {
return /* article body JSX */;
}
function renderFooter() {
return /* article footer JSX */;
}
}
- I typically prefix these functions with
render
to differentiate them from other functions that don't return JSX. - I place these functions right after the
return
statement so that everything related to content is grouped together. - There's no need to pass any arguments to these functions, as they have access to all props and locally defined variables.
There's a debate regarding partial render functions that suggests avoiding returning JSX from any local function defined inside a component. An alternative would be to extract these functions as separate components.
export function Article(props: Props) {
// ...
return (
<article>
<h1>{props.title}</h1>
<ArticleBody {...props} />
<ArticleFooter {...props} />
</article>
);
}
function ArticleBody(props: Props) {}
function ArticleFooter(props: Props) {}
This approach works well in some isolated situations. However, we'll have to manually pass as props all the required local variables that the subcomponent needs. Consequently, when using TypeScript, we usually end up defining additional complex types for the components' props.
In the end, what we get is bloated code, which often obscures its reading and understanding.
export function Article(props: Props) {
const [status, setStatus] = useState("");
return (
<article>
<h1>{props.title}</h1>
<ArticleBody {...props} status={status} />
<ArticleFooter {...props} setStatus={setStatus} />
</article>
);
}
interface BodyProps extends Props {
status: string;
}
interface FooterProps extends Props {
setStatus: Dispatch<SetStateAction<string>>;
}
function ArticleBody(props: BodyProps) {}
function ArticleFooter(props: FooterProps) {}
Keep in mind that these separate components:
- are not reusable; they are only used by the component they belong to;
- are not cohesive; they don't make sense to be used on their own.
Extracting these components in separate files usually increases indirection, which ultimately obscures even more the way we read and understand the entire component.
Thus, I prefer the pragmatic approach in this case, as it makes more sense for practical considerations.
Local functions
UI components will often include event handlers, which are nested functions that usually alter the component's internal state or dispatch an action to update the application state.
Another category of nested functions is closures, which are impure functions that read local state or props, helpful in structuring our component logic.
export function Article(props: Props) {
const [email, setEmail] = useState("");
return (
<article>
{/* ... */}
<form onSubmit={subscribe}>
<input type="email" value={email} onChange={setEmail} />
<button type="submit">Subscribe</button>
</form>
</article>
);
// event handler
function subscribe(): void {
if (canSubscribe()) {
// send subscribe request
}
}
// closure
function canSubscribe(): boolean {
// validation logic based on props & state
}
}
It's worth mentioning that:
- I always use function declarations instead of function expressions because they are hoisted, which allows me to define them after their usage. This wayI can place them at the end of the component function, after the
return
statement; - If a function calls another function, I always place the caller before the callee.
- I usually put these functions in the order of their usage.
Pure functions
Last but not least, we have pure functions, which we can easily place at the bottom of the file, outside the React component:
// React component
export function Article(props: Props) {
// ...
// ❌ pure functions should not be placed inside the component
function getInitials(str: string) {}
}
// React component
export function Article(props: Props) {
// ...
}
// ✅ pure functions should be placed outside
function getInitials(str: string) {}
First of all, pure functions don't have dependencies, like props, state, or local variables, because they receive all dependencies as arguments. This means that we can place them virtually anywhere. However, there are also additional reasons to place them outside the function that defines our component:
- It signals to any developer reading the code that they are pure.
- They are easy to test. We only need to export the functions we want to test and import them into the test file.
- They are easy to move to other files in case we need to extract and reuse them.
Complete example
Here's a complete typical React component file. The implementation details are left out because the focus is on the file structure.
// 1️⃣ dependencies imports
import React from "react";
import { Tag } from "./tag";
import styles from "./article.module.scss";
// 2️⃣ static definitions: constants & types
const MAX_READING_TIME = 10;
interface Props {
id: number;
name: string;
title: string;
meta: Metadata;
}
// 3️⃣ component definition
export function Article(props: Props) {
// 4️⃣ variables declarations
const router = useRouter();
const theme = useTheme();
const { id, title, content, onSubscribe } = props;
const { image, author, date } = meta;
const [email, setEmail] = React.useState("");
const [showMenu, toggleMenu] = React.useState(false);
const summary = React.useMemo(() => getSummary(content), [content]);
const initials = getInitials(author);
const formattedDate = getDate(date);
// 5️⃣ effects
React.useEffect(() => {
// ...
}, []);
// 6️⃣ returned content
return (
<article>
<h1>{title}</h1>
{renderBody()}
<form onSubmit={subscribe}>
{renderSubscribe()}
</form>
</article>
);
// 7️⃣ partial renders
function renderBody() { /*...*/ }
function renderSubscribe() { /*...*/ }
// 8️⃣ local functions, event handlers
function subscribe() { /*...*/ }
}
// 9️⃣ pure functions
function getInitials(str: string) { /*...*/ }
To conclude
As we read code more often than we write it, the structure of our code should aid us as much as possible in this regard. Therefore, clearness is one of my primary goals when writing code.
As Grady Booch said, code should read like "well-written prose", so most of the inspiration for the structure presented in this article I got from written literature, such as books or articles:
- Organize the content to make sense for the reader.
- Present an overview first and the details later. It doesn't help to read the details before you get a general idea about the topic.