Following are notes for my talk in KL React Meetup. There is no certain point that I want to make, this is more like a collection of thoughts that I have about abstraction in React.
Or you can see it as my attempt to defend my abstraction to my colleagues that are annoyed by my abstraction.
What is a good abstraction
We create abstraction to:
- reduce cognitive cost by hiding complexity from end users
- provide stable interfaces while allowing internal implementation to change
We judge the quality of abstraction based on how much they achieve these goals.
Why abstraction is an art
Because abstraction requires balancing between multiple qualities.
- Sometimes pursuing one goal of abstraction sacrifices other goal
- Sometimes making good abstraction sacrifices other goals of software engineering, e.g. maintenance effort and performance.
Let’s look at a few tradeoffs that we often face when creating abstraction in React.
Tradeoff #1: Flexibility vs Simplicity
I also like to call this tradeoff as tradeoff between short-term and long-term cognitive cost.
- Flexibility: powerful, more options/parameters, more capable but higher learning curve, in other words, higher short-term cognitive cost but probably lower long-term cognitive cost as once you understand it, you can use it to model more use cases.
- Simplicity: easier to understand, lower learning curve. In other words, lower short-term cognitive cost.
// Simple
const asyncState = useAsync(() => apiCall(), []);
asyncState.loading;
asyncState.error;
asyncState.value;
// Flexible and more powerful
const queryState = useQuery({
queryFn: () => apiCall(),
queryKey: ['queryKey'],
staleTime: 1000,
});
queryState.isLoading;
queryState.error;
queryState.data;
Tradeoff #2: Generality and Specificity
- General - more reusable but harder to optimize. Also encapsulates less complexities.
- Specificity - less reusable, but can encapsulates more complexities. Easier to optimize as well.
import * as React from 'react';
import { clsx } from 'clsx';
// General: encapsulates styling
const Button = (props: React.ComponentProps<'button'>) => {
return (
<button {...props} className={clsx('px-3 py-1 rounded-md', props.className)} type="button" />
);
};
// Specificity: specific - encapsulates logic of auth and permission
const ResourceButton = ({
resourceName,
action,
disabled,
...props
}: React.ComponentPropsWithoutRef<typeof Button> & {
resourceName: string;
action: 'create' | 'update' | 'delete';
}) => {
const { isAllowed, isLoading } = useResourcePermission({
resourceName,
action,
});
return <Button {...props} disabled={disabled || isLoading || !isAllowed} />;
};
Tradeoff #3: Effort of Maintainer vs Consumer
To provide better experience for consumer (less mandatory data, better type inference) often requires maintainer to invest more efforts into creating and maintaining the abstraction.
Patterns
Now that I have discussed the common tradeoffs, I would like to share a few patterns that I use to create abstraction in React.
I mark them as “recommended” when I think they are good patterns to follow most of the time. Else, I mark them as “if appropriate” when the pattern is only useful in certain situations.
I am not attempting to cover all patterns here, but only the ones that I used to ignore. Think of the list as “patterns I wish I knew” instead.
Components
- Recommended: Once you have components that can be composed together, abstract the most common composition as a component.
// Having those components working together is good
<Field>
<Label>Field label</Label>
<TextInput />
</Field>;
// Since it's so common, create a component for that!
const TextField = ({
label,
...props
}: React.ComponentPropsWithoutRef<typeof TextInput> & { label: string }) => {
return (
<Field>
<Label>{label}</Label>
<TextInput {...props} />
</Field>
);
};
- Recommended: Create wrapper component that integrates with library that you choose:
import { useForm } from 'react-hook-form';
// Instead of sprinkle "register" everywhere when you use react-hook-form...
const CommonExample = () => {
const { register } = useForm({});
return (
<form>
<TextField {...register('name')} />
<TextField {...register('email')} />
</form>
);
};
// Create component that integrate with react-hook-form automatically
import { FormProvider, useController, type UseFormReturnType } from 'react-hook-form';
const Form = (props: { form: UseFormReturnType; children: React.ReactNode }) => {
return <FormProvider {...props} />;
};
const FormTextField = ({ name, ...props }: TextFieldProps & { name: string }) => {
const controller = useController({
name,
});
return <TextField {...controller.field} />;
};
// Then the noise are mostly gone
const AbstractedExample = () => {
const form = useForm({});
return (
<Form form={form}>
<FormTextField name="name" />
<FormTextField name="email" />
</Form>
);
};
- If appropriate: Don’t provide props when there is no use case for it.
- If appropriate: Spread props to “main” component. When in doubt, don’t spread.
- If appropriate: Use render props to increase flexibility.
import { clsx } from 'clsx';
const Button = ({
variant,
render,
className: providedClass,
children,
...btnProps
}: React.ComponentProps<'button'> & {
variant: 'primary' | 'secondary';
render: (btnProps: { className: string; children: React.ReactNode }) => React.ReactElement;
}) => {
const className = clsx(
{
primary: 'bg-blue-500',
secondary: 'bg-white border-blue-500 border',
}[variant],
providedClass
);
if (render) {
return render({
className,
children,
});
}
return (
<button type="button" className={className} {...btnProps}>
{children}
</button>
);
};
Hooks
- Recommended: Use object options mostly, unless you’re modeling after built-in hook.
// Nope
const useTheme = (defaultTheme?: string) => {};
// Yes
const useThemeValue = (options: { defaultTheme?: string }) => {};
// Maybe yes, since we try to model after useState
const usePersistedState = <Value>(defaultValue: Value, options: { storageKey: string }) => {};
- Recommended: Returns an object or a tuple, but never single value because you can’t add additional properties without a breaking change.
General
Following are patterns that apply to both components and hooks.
- Recommended: Prefer single variant value instead of multiple booleans
// Instead of this...
type AsyncState<Value> = {
isLoading: boolean;
isError: boolean;
isLoaded: boolean;
value: Value | undefined;
error: Error | undefined;
};
// Prefer this
type AsyncStateV2<Value> =
| {
state: 'loading';
}
| {
state: 'error';
error: Error;
}
| {
state: 'loaded';
value: Value;
};
- Recommended: When increasing flexibility of an abstraction (adding more options), add docs to illustrate the use case (comments, storybook, tests, or markdown).
- If appropriate: Use factory function to enforces conventions and for more aggressive abstractions.
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
const createResourceQueries = <
Resource extends object,
CreateInput extends object = Omit<Resource, 'id'>,
>({
resourceKey,
api,
}: {
resourceKey: string;
api: {
getAll: () => Promise<{ items: Resource[] }>;
getOne: (resourceId: string) => Promise<Resource>;
createOne: (input: CreateInput) => Promise<Resource>;
};
}) => {
return {
useGetAll: () =>
useQuery({
queryKey: [resourceKey, 'getAll'],
queryFn: () => api.getAll(),
}),
useCreateOne: () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateInput) => api.createOne(input),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: [resourceKey],
}),
});
},
};
};
Additional thoughts about abstractions
- Account for Conway’s law when understanding abstraction: “Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization’s communication structure.”
- It is okay to be imperfect or wrong, and be forgiving when others get it wrong. Remember the law of leaky abstraction: All non-trivial abstractions, to some degree, are leaky.
- Since all abstractions are leaky, don’t get annoyed when you see abstractions that you find imperfect. Instead, read the code and understand the tradeoffs, which would results in either you coming up with a better abstraction, or a deeper understanding of the problem.
A metaphor for abstraction.
Abstraction is like a business
- A business can be highly focused (solve one problem really well) vs all-in-one platform (one-stop business).
- A business aims to hide the complexity and expose a friendly interface to end users
- A business could delegates to other businesses for certain parts of the operations, and being a coordinator between them for the customers.
- Don’t create a business and then find customers later. Identify customers first before start a business. Similarly, don’t create an abstraction and then find how to use it. Instead, abstract only when you identify the common problems of your domain.
- A business model that succeeds in an environment may not survive in another one. So, get inspired by what others are doing, but don’t simply copy them.
Summary
- Abstraction is an art. The goals are clear, but because those goals may be in conflict of each others, at certain point abstraction may not be improved any further, but just tradeoff of one quality over another.
- Common abstraction in React are component, hook, and factory function. There are few patterns that I find useful to improve those abstractions.
- It’s okay to get it wrong. As a very smart person once said, “most models are wrong, but some are useful.”