Enterprise-grade React
At Uplimit, everyone is a product engineer. I estimate that about 50% of our time is spent working with React. There’s really no specialization where any particular person works specifically on something because we’re essentially product-first as a company — even if you don’t know React well, you have to write it here. Because of this, good React code is really important for us to not accumulate unnecessary tech debt. What makes this challenging is that we, as a startup, can’t spend too much time addressing React code comments because we want to move fast and not block each other from shipping. I’ve been working with React for a while now, so here are some good patterns when writing React and, more specifically, when writing a UI kit / design system at a startup.
1. Composability
This is most relevant when you’re building a design system / UI kit. Serious companies tend to develop a design system, and with it comes building a UI kit. A UI kit is an interesting artifact because if you don’t set it up well, you’re inviting bad usage patterns that will either make engineers who use it want to build their own little version of the same component, or making changes to the original component in the UI kit might introduce a lot of incompatibilities with other places that use the component. To avoid this problem, we need to lay down some groundwork. There are two types of engineers that will use your UI kit:
- Engineers who can’t care less about the UI and just want to be able to use the components to quickly do what they need to do — they are probably more backend-savvy, yet they’re in a startup that requires them to do frontend work, so they want to finish the UI part as quickly as possible.
- Engineers who are more frontend-savvy, and their task requires them to build more complex components. They will require flexibility in the UI kit so that they can build a small variation of the same component, and this variation is more domain-specific to their use case in such a way that it doesn’t make sense to create that variation in the UI kit, so it ends up being their own variation built specifically for their use case instead.
These two types of engineers have different needs. Your UI kit will need to be flexible enough to accommodate the second type, but also easy enough to be used out-of-the-box by the first type. This becomes more important as your team grows.
Let’s give an example. One of the big UI components that we developed at Uplimit is the DataTable. The reason why it’s so important is that as a company serving many great enterprises who want to reimagine learning for their workforce, we show a lot of data everywhere, which we use tables for. It’s also one of the more complex components to build, mostly because it has many smaller components (e.g., search inputs, filters, etc.) which are all composed of smaller existing components from our kit.
I knew at the beginning that most people would want to slap a <DataTable data={data} /> component on their page and be done with it, but sometimes they would want variations of this component, so it was evident to me that it needed to be composable — if you want to make variations to it, it should feel like you’re a child playing with LEGOs, crafting them together and getting your desired model right away. So this big DataTable component needs to be split up into smaller, more manageable pieces:
Which produces subcomponents:
<DataTableSearchInput /><DataTableFilterCreator /><DataTableFilterChip />- and so on…
packages/ui/DataTable
│── components/
│ ├── DataTableSearchInput.tsx
│ ├── DataTableFilterCreator.tsx
│ ├── DataTableFilterChip.tsx
│ └── ...
└── index.ts
From these sub-components, we build a big component that allows you to use a standard DataTable with most of the default settings included. We should also allow engineers to easily use a variation of the DataTable component. The key to “variations” is to offer React props to the component, whereby those props can control the behavior of the component, both in terms of its UX behavior and its UI (i.e., a prop to hide a subcomponent or a behavior that you don’t need). For our DataTable, it’s a lot of “I don’t need the column filters” or “I want to change the icon of the part that shows the number of rows currently selected,” etc., so we make those as props.
// DataTable.tsx
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
filters?: Filter[];
}
export const DataTable = <T,>({
data,
columns,
filters,
}: DataTableProps<T>) => {
return (
<div className="flex flex-col gap-4 p-4 border border-gray-200 rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<DataTableSearchInput className="flex-1" />
<DataTableFilterCreator className="ml-4" />
</div>
<div className="flex items-center gap-2">
{filters?.map((filter) => (
<DataTableFilterChip key={filter.id} filter={filter} />
))}
{/* ... more components */}
</div>
</div>
);
};
packages/ui/DataTable
│── components/ # <-- sub-components
│ ├── DataTableSearchInput.tsx
│ ├── DataTableFilterCreator.tsx
│ ├── DataTableFilterChip.tsx
│ └── ...
│ DataTable.tsx # <-- OOTB, main component
└── index.ts
And of course, you expose everything in the index.ts file, so that it can be used as a module:
// index.ts
export * from "./DataTable";
export * from "./components/DataTableSearchInput";
export * from "./components/DataTableFilterCreator";
export * from "./components/DataTableFilterChip";
2. Be close to HTML primitives
One thing that I have encountered when working with people on React is that it is easy for us to reinvent HTML primitives that were already given by React. React allows you to build stateful apps, but that does not mean useState and useEffect should be the first tools you pull out of your engineer’s toolbox when you want to build some sort of interactions. The thing is: as soon as you use React states, you’re one step further from accessibility. The simplest example is creating a form, e.g., login forms, forms for creating a new object in your database, etc., which many would try to use useState to model the text state of the form:
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// ...
return (
<div>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
<button
onClick={async () => {
await login(email, password);
}}
>
Submit
</button>
</div>
);
}
The problem with this is you lose quite a few accessibility points: the biggest one being that the Enter key does not submit the form, but that’s almost always expected for a form. At minimum, you could easily use what React exposes for <form> elements, being onSubmit and the FormData object to do what they need to do. Only when you DO need to control the inputs a bit (for example, forcing that the input must only be lowercase characters), then you should reach for useState to do that.
Accessibility is not the only problem that is borne when you don’t leverage HTML primitives. The other problem is that it’s harder for people to extend or build around your UI kit. This would make the second type of engineer we outlined above — the one who needs more granular controls over their components — have a harder time building upon our UI kit. Take our <DataTableSearchInput /> component, which is essentially a search input that’s styled a bit differently. Here’s a suboptimal way to implement it:
interface DataTableSearchInputProps {
searchValue: string;
onSearch: (value: string) => void;
}
export const DataTableSearchInput: React.FC<DataTableSearchInputProps> = ({
search,
onSearch,
}) => {
// ... some states stuff
return (
<Input
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
// ... other props
/>
);
};
This looks fine at first glance, but the problem here is you lose the ability to hook into more events on the input. For example, you can’t use the onKeyDown event which React’s HTML attributes provide out of the box. You also can’t use the ref prop to access the native input element, which is a problem if you want to do something like focus the input when the component mounts.
To solve this, the solution is to be as close to the HTML primitives as possible, so that you can use the native events and properties. Essentially, at the end of the day, you are wrapping a HTML element, with some styling and behavioral changes. Most components in a typical modern web application are easily replicable with HTML primitives. The rule of thumb is to 1) use forwardRef to allow people to access the native element, and 2) make your props extend the native props, and the additional props that you add are custom ones that make people’s lives easier or something that controls the appearance or behavior of the component.
interface DataTableSearchInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
// extend the native props of an input element
onClear?: () => void; // there's no onClear native prop, so we add it
}
export const DataTableSearchInput = React.forwardRef<
HTMLInputElement,
DataTableSearchInputProps
>(({ onClear, ...props }, ref) => {
// ...
return (
<Input
ref={ref}
className={cn(props.className, "rounded-full")} // styles
{...props}
/>
);
});