React
Prerequisite
Before reading this guide, you should be familiar with React. Please read every single page in the React documentation.
In this page we lay out some conventions to follow when developing a React application in Hyperjump so that the code is easier to understand and test.
Do not use useEffect unnecessarily
One of the most common newbie mistakes when using React is to use useEffect unnecessarily. There's even a page in React documentation that explains that you might not need an Effect. Please read it thoroughly.
Let's see a bad example:
const Component = () => {
const [data, setData] = useState<string[]>([]);
const [message, setMessage] = useState<string>("");
useEffect(() => {
setMessage(`Hello, ${data.join(", ")}!`);
}, [data]);
const handleClick = () => {
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
setData(data);
});
};
return (
<div>
<p>{message}</p>
<button onClick={handleClick}>Fetch Data</button>
</div>
);
};In that example, the message state and the effect to update the message are not necessary (line 3-7). The message variable can be computed from the data state using a simple expression as follows:
const Component = () => {
const [data, setData] = useState<string[]>([]);
const message = `Hello, ${data.join(", ")}!`;
const handleClick = () => {
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
setData(data);
});
};
return (
<div>
<p>{message}</p>
<button onClick={handleClick}>Fetch Data</button>
</div>
);
};Rule of thumb
If a "state" can be computed, do not use Effect to update it.
Always encapsulate the logic in a custom hooks
If you look at a React component, e.g., the component.tsx in the previous example, we can actually see that it has two parts:
- The code that constructs the UI. This is the JSX code that is returned by the component.
- The logic that is used to update the state and run effects. This is every line of code from the opening curly brace
{to thereturnstatement.
As experience has shown, when we have so many lines of code in the logic part, it is difficult to understand and test. Imagine if we have a component with 100 lines of code in the logic part. It takes more time and effort to even find where the UI part is.
In the example above, we can encapsulate the logic in a custom hook as follows:
const useData = () => {
const [data, setData] = useState<string[]>([]);
const fetchData = useCallback(() => {
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
setData(data);
});
}, []);
const message = `Hello, ${data.join(", ")}!`;
return useMemo(
() => ({ data, fetchData, message }),
[data, fetchData, message]
);
};Good to know
The name of the custom hook has to be prefixed with use. This will help
react's hook linter to lint the custom hooks to follow the rule of
hooks.
Good to know
Once React compiler is stable and available by default in Next.js, we won't
need the useCallback and useMemo anymore.
Then we can use the custom hook in the component as follows:
const Component = () => {
const { data, fetchData, message } = useData();
return (
<div>
<p>{message}</p>
<button onClick={fetchData}>Fetch Data</button>
</div>
);
};As you can see, the component is now much simpler and easier to understand. The logic is encapsulated in the custom hook and the component is only responsible for the UI. It is also easier to understand the logic. We understand immediately from its name (useData) that the custom hook is to fetch the data, and simply return both the data and the message.
Rule of thumb
A React component must not have any useState and useEffect statements.
All the state management and effect handling should be encapsulated in custom
hooks.
Do not create inline functions as component
Another common mistake that makes a component hard to understand is to create inline functions that return JSX. Let's see a bad example:
const Tabs = ({ title }: { title: string }) => {
const createTab = (label: string) => {
return (
<div>
<p>
{title}:{label}
</p>
</div>
);
};
return (
<div>
{createTab("Tab 1")}
{createTab("Tab 2")}
{createTab("Tab 3")}
</div>
);
};In that example, the createTab function pollutes the Tabs component. It has nothing to do with the Tabs component. Imagine if the function has hundreds of lines of code. It is difficult to understand what it is for.
The createTab function is essentially a React functional component since it returns JSX. It's so much better to define it as a separate component as follows:
const Tab = ({ title, label }: { title: string; label: string }) => {
return (
<div>
<p>
{title}:{label}
</p>
</div>
);
};Then we can use the Tab component in the Component component as follows:
const Component = ({ title }: { title: string }) => {
return (
<div>
<Tab title={title} label="Tab 1" />
<Tab title={title} label="Tab 2" />
<Tab title={title} label="Tab 3" />
</div>
);
};Now, not only the Tabs component is much simpler and easier to understand, but also the Tab component is a separate component and it is easier to understand what it is for.
Rule of thumb
Do not create inline functions that return JSX. Instead, create a named function and use it.
Use HOC aggresively
Concretely, a higher-order component (HOC) is a function that takes a component and returns a new component. This is another way to keep the components clean and modular just like the custom hooks.
Let's see a very simple example. Say we have a component that displays a "Logout" button when the user is logged in and a "Login" button when the user is not logged in. Usually, we would write a component like this:
const AuthButton = () => {
const isLoggedIn = useIsLoggedIn();
return (
<div>{isLoggedIn ? <button>Logout</button> : <button>Login</button>}</div>
);
};We already use a custom hook, which is great! But the problem with this approach is that the AuthButton component still needs to actively "get" a piece of data (isLoggedIn in this example) to correctly render the component. We can make this component dumber simpler by removing the custom hook call and instead pass the isLoggedIn data as a prop to the component.
const AuthButton = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
return (
<div>{isLoggedIn ? <button>Logout</button> : <button>Login</button>}</div>
);
};Now the AuthButton component only focuses on rendering the buttons conditionally. But how do we get the isLoggedIn data? We can create a higher-order function to do this.
import { useIsLoggedIn } from "../hooks/use-is-logged-in";
import type { ComponentProps } from "react";
export const withAuth = <T extends ComponentProps<"div">>(
Component: React.ComponentType<T & { isLoggedIn: boolean }>
) => {
const WithAuth = (props: T) => {
const isLoggedIn = useIsLoggedIn();
return <Component {...props} isLoggedIn={isLoggedIn} />;
};
type WithoutAuthProps = Omit<T, "isLoggedIn">;
return WithAuth as React.ComponentType<WithoutAuthProps>;
};Then we can use the withAuth higher-order function to wrap the AuthButton component as follows:
export default withAuth(AuthButton);The great thing about this approach is that we can now create another new component that needs isLoggedIn data as a prop by simply wrapping the component with the withAuth higher-order function.
Prevent impossible states
The following is a bad code. Can you tell why?
const MyComponent = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [success, setSuccess] = useState(false);
return (
<div>
{loading && !error && !success && <p>Loading...</p>}
{error && !loading && !success && <p>Error occurred</p>}
{success && !loading && !error && <p>Operation completed successfully</p>}
</div>
);
};The code above is bad because it doesn't prevent impossible states from happening. For example, loading can be true while success is true. What we want is actually an exclusive state machine: of all three states, only one can be true at any given time, like this:
import { useState } from "react";
type State = "loading" | "error" | "success";
const useStateMachine = () => {
const [state, setState] = useState<State>("loading");
const handleClick = () => {
setState("loading");
// Simulate an async operation
setTimeout(() => {
setState("success");
}, 2000);
};
return { state, handleClick };
};
const MyComponent = () => {
const { state, handleClick } = useStateMachine();
return (
<div>
{state === "loading" && <p>Loading...</p>}
{state === "error" && <p>Error occurred</p>}
{state === "success" && <p>Operation completed successfully</p>}
<button onClick={handleClick}>Click me</button>
</div>
);
};Controlled and uncontrolled components in a form
It is very common to use a state to store the value of an input in a form like this:
const MyComponent = () => {
const [value, setValue] = useState("");
return (
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
};Now imagine we have 10 or more inputs in the form. We will have to create 10 or more state variables to store the value of each input. It could get messy quickly.
When your input field has value property and onChange handler, it becomes a controlled component. Before turning inputs in a form into controlled components, you should ask yourself if you need it to be controlled because adding states adds complexity, introduces potential bugs, more code in the bundle, and more CPU cycles.
These are some cases where you think you need to use controlled inputs but you might not need it.
Collecting input values
One of the common cases to use controlled inputs is to collect the input values and send to an API route like this:
const MyComponent = () => {
const [value, setValue] = useState("");
const handleSubmit = async () => {
await fetchData({
body: { value },
});
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
</div>
);
};However, if we are using a server function to handle the form submission, we don't need to use controlled inputs.
import { useActionState } from "react";
import { serverFunction } from "./some-server-function";
const MyComponent = () => {
const [state, action, pending] = useActionState(serverFunction, null);
return (
<div>
<form action={action}>
<input type="text" name="username" />
<button type="submit">Submit</button>
</form>
</div>
);
};export const serverFunction = async (formData: FormData) => {
const username = formData.get("username");
return { username };
};As you can see, we just need to make sure that every input field has a name attribute so that the server function can extract the value of the input field from the form data.
Good to know
For more information on using server functions, please refer to the Updating Data guide.
Input validation
Another common case to use controlled inputs is to validate the input values before sending data to the server. Here's some things you should consider before turning your input fields into controlled components.
- Your server side code, whether it's a server function or a route handler, will validate the input values anyway because data from external sources must always be validated. So consider if it's acceptable to validate the inputs in the server and simply show the validation errors in the client if any.
- The modern browser has built-in validation for input fields. For example, if you use the
requiredattribute, the browser will show a validation error if the input field is empty. Of if you addtype="email", the browser will show a validation error if the input field is not a valid email address. Consider the built-in HTML attributes like pattern, minLength, required, input type, etc. - For validation rules that are not supported natively by the browser, consider using useResettableActionState to validate the input values before data is sent to a server function. For example, you might want to make sure two input fields have the same value (e.g., password and confirm password) like this:
"use client";
import { doSomething } from "./actions";
import { useResettableActionState } from "use-resettable-action-state";
export default function Form({
initialState,
}: {
initialState: { password: string | null; error: string | null };
}) {
const [state, submit, isPending, reset, payload] = useResettableActionState(
doSomething,
initialState,
undefined,
async (payload, abortController) => {
if (payload?.get("password") !== payload?.get("repeat-password")) {
abortController.abort({
error: "Passwords do not match",
});
}
return payload;
}
);
return (
<form action={submit}>
{state && !state.error && <p>Success!</p>}
{state && state.error && (
<p className="bg-red-500 text-white p-4">{state.error}</p>
)}
<input
type="password"
name="password"
id="password"
placeholder="Enter new password"
/>
<input
type="password"
name="repeat-password"
id="repeat-password"
placeholder="Repeat the new password"
/>
<p>{state && state.data?.message}</p>
<button disabled={isPending} type="submit">
{isPending ? "Loading..." : "Submit"}
</button>
</form>
);
}Use controlled inputs when all three conditions above are not met.
Using third party form libraries
There are many third party form libraries that can help you manage forms in a more declarative way. For example, React Hook Form and Formik.
TODO
Discuss and write down when to use third party form libraries.
Compound pattern
The compound pattern is a way to create a component that is a composition of multiple components. It is a way to keep the components clean and modular. Let's say we want to create a Tabs component. It consists of two main parts:
- The list of tabs: it shows all the available tabs, which tab is currently active (selected), and allows the user to switch between tabs.
- The content of the tabs: it shows the content of the currently active tab.
Without compound pattern
A straightforward approach is to create a single component that handles both the list of tabs and the content of the tabs.
const Tabs = ({
tabs,
}: {
tabs: {
key: string;
label: string;
content: React.ReactNode;
isActive: boolean;
}[];
}) => {
const [activeTab, setActiveTab] = useState(
tabs.find((tab) => tab.isActive)?.key
);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
{tabs.map((tab) => {
const isActive = tab.key === activeTab;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
isActive ? "bg-blue-500 text-white" : "bg-gray-500 text-white"
)}
>
{tab.label}
</button>
);
})}
</div>
<div>{tabs.find((tab) => tab.key === activeTab)?.content}</div>
</div>
);
};Then we can use the Tabs component like this:
const Component = () => {
return (
<Tabs
tabs={[
{
key: "tab1",
label: "Tab 1",
content: <div>Tab 1 content</div>,
isActive: true,
},
{
key: "tab2",
label: "Tab 2",
content: <div>Tab 2 content</div>,
isActive: false,
},
{
key: "tab3",
label: "Tab 3",
content: <div>Tab 3 content</div>,
isActive: false,
},
]}
/>
);
};The problem with this approach is that the Tabs component is getting too complex and it has no separation of concerns. It is responsible for both the list of tabs and the content of the tabs. It is also responsible for the state management of the active tab. It is also responsible for the logic to switch between tabs. It simply does too much.
With compound pattern
We can use the compound pattern to break down the Tabs component into smaller components that are responsible for a single responsibility:
- The
Tabscomponent: the parent component that manages the state and delegates rendering to the child components. - The
TabListcomponent: the component that renders the list of tabs. - The
Tabcomponent: the component that renders a single tab. - The
TabPanelscomponent: the component that renders the content of the tabs. - The
TabPanelcomponent: the component that renders the content of the currently active tab.
// Create a context to share state between components
const TabsContext = createContext(null);
// Parent component manages state but delegates rendering
const Tabs = ({ children, defaultActiveKey }) => {
const [activeKey, setActiveKey] = useState(defaultActiveKey);
return (
<TabsContext.Provider value={{ activeKey, setActiveKey }}>
<div className="tabs-container">{children}</div>
</TabsContext.Provider>
);
};
// Child components with default styling
const TabList = ({ children, className = "flex flex-row gap-2" }) => {
return <div className={className}>{children}</div>;
};
const Tab = ({ children, tabKey, disabled, className }) => {
const { activeKey, setActiveKey } = useContext(TabsContext);
const isActive = activeKey === tabKey;
const defaultClassName = isActive
? "bg-blue-500 text-white px-4 py-2 rounded"
: "bg-gray-500 text-white px-4 py-2 rounded";
return (
<button
disabled={disabled}
onClick={() => !disabled && setActiveKey(tabKey)}
className={className || defaultClassName}
>
{children}
</button>
);
};
const TabPanels = ({ children, className = "mt-4" }) => {
return <div className={className}>{children}</div>;
};
const TabPanel = ({ children, tabKey }) => {
const { activeKey } = useContext(TabsContext);
if (activeKey !== tabKey) return null;
return <div className="tab-panel">{children}</div>;
};
// Attach child components to the parent
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.TabPanel = TabPanel;Then we can use the Tabs component with its default styling like this:
<Tabs defaultActiveKey="tab1">
<Tabs.TabList>
<Tabs.Tab tabKey="tab1">First Tab</Tabs.Tab>
<Tabs.Tab tabKey="tab2">Second Tab</Tabs.Tab>
<Tabs.Tab tabKey="tab3" disabled>
Disabled Tab
</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel tabKey="tab1">
<p>Content for first tab</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabKey="tab2">
<p>Content for second tab</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabKey="tab3">
<p>Content for disabled tab</p>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>Or we can have Tabs component with custom styling like for the tabs list and the panels:
<Tabs defaultActiveKey="tab1">
<Tabs.TabList className="flex flex-col space-y-2 border-r pr-4">
<Tabs.Tab
tabKey="tab1"
className="text-left hover:bg-gray-100 px-4 py-2 rounded-l"
>
First Tab
</Tabs.Tab>
<Tabs.Tab
tabKey="tab2"
className="text-left hover:bg-gray-100 px-4 py-2 rounded-l"
>
Second Tab
</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels className="pl-4 flex-1">
<Tabs.TabPanel tabKey="tab1">
<p>Content for first tab</p>
</Tabs.TabPanel>
<Tabs.TabPanel tabKey="tab2">
<p>Content for second tab</p>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>With compound pattern, we have a clear separation of concerns and more flexibility.
Rule of thumb
Always aim to keep the component simple with minimum responsibility. Use the compound pattern to further break down the component into smaller components that are responsible for a single responsibility.
Ground Rules
| Action | |
|---|---|
| ✅ | Don't use useEffect unnecessarily. Compute the value if possible. |
| ✅ | Always encapsulate the logic in a custom hook. |
| ✅ | Do not create inline functions that return JSX. Instead, create a separate component. |
| ✅ | Use HOC to further reduce the complexity of a component. |
| ✅ | Be cautious about adding states to a component. |
| ✅ | Use uncontrolled inputs in forms when possible. |
| ✅ | Use compound pattern to have a clear separation of concerns and more flexibility. |
Todos
- Search for or create a linter rule to enforce the rule: "A React component must not have any
useStateanduseEffectstatements. All the state management and effect handling should be encapsulated in custom hooks." Doc: React - Search for or create a linter rule to enforce the rule: "Do not create inline functions that return JSX. Instead, create a separate component." Doc: React