-
Notifications
You must be signed in to change notification settings - Fork 91
Frontend codestyle and patterns
Here is an overview of the rules we follow in the frontend code.
- Functions must always have a return type.
- A module should not export other functions than those used outside the module. The exception is when we want to have a direct unit test for something.
- Functions used internally by another function should be located in the same folder as the function they are used in.
- Object types that are subject to many operations should be defined as classes. Related functions should be grouped within this class instead of being defined in a separate
utilsfile. - Ternary expressions must not be nested. Instead, use
else if(orswitchif applicable) or split the expression into multiple functions. All forms of nested expressions violate the principle of having only one level of abstraction.
- Files and folders exporting a single item must have the same name as the item they export. For example, a file exporting only the type
ExampleTypeshould be namedExampleType.ts. The exception is when it is designed to export multiple items in the same category. For example, a file may be namedconstants.tseven if it only exports one constant. - Files and folders exporting multiple items should use "kebab case". This means words are separated by hyphens. For example,
dom-utilsfor a folder exporting several functions related to the DOM model. - The suffix
utilsshould be used for files and classes that group functions for a given data type or component. - Types used in network requests should have one of the suffixes
Payload,Response, orParams, depending on the role of the type. - Lists should be named either in the plural form of the entity being listed (e.g.,
options) or with a suffix likeListorGroup(e.g.,optionList).
We use Jest as the testing framework for TypeScript.
Unit tests for a given function should be grouped in a describe block named after the function. We also group all tests in the same file into one large describe block, as some code tools (e.g., WebStorm) provide utilities that make it easy to trigger all tests in one such block vg .
Example:
describe('filename', () => {
describe('function1', () => {
it('Returns true when given a string', () => {
const result = function1('test');
expect(result).toBe(true);
});
it('Returns false when given a number', () => {
const result = function1(12);
expect(result).toBe(false);
});
});
describe('function2', () => {
it('Returns the given string backwards', () => {
const result = function2('abc');
expect(result).toBe('cba');
});
});
});The it and test functions are equivalent. Use it when, combined with the first parameter, it reads as a sentence, as in the examples above. In all other cases, use test.
Sometimes we need to choose between using an enum or a type that simply specifies the valid values for a variable.
Example of an enum:
enum Size {
Small = 'small',
Medium = 'medium',
Large = 'large',
}The same values as a union type:
type Size = 'small' | 'medium' | 'large';The advantage of an enum is that it is easy to locate where it is used and, since it is available in runtime, it is possible to iterate over the values. However, it requires more code to define and must always be imported when used. Therefore, we follow this rule:
Use an enum in cases where we need to iterate over the valid values at least once in the code. In all other cases, use union types.
React components should only contain code related to the presentation of data. Data processing should be defined in separate files that are not dependent on the React framework. This means a React component should not have any knowledge of the structure of the data it processes. All data extraction and modification should be handled by functions defined outside the component code.
This principle makes it easy to test the data processing, since the tests don't need to take the complexity of React and DOM concepts into account. They only need to focus on input and output values. On the other side, the React component tests must cover presentation and user interactions, but they con't need to care about the details of the data.
The following example presents two text fields that accept an integer number and the sum of these two numbers. The sum is calculated using a function named add, which is defined outside of the component code and has its own unit tests. This function takes care of all the data processing: It converts the two terms from strings to numbers, adds them together and parses the result back to a string. Thus, the component code only contains state and event handling and the presentation JSX.
function App(): React.ReactNode {
const [firstTerm, setFirstTerm] = React.useState<string>('0');
const [secondTerm, setSecondTerm] = React.useState<string>('0');
const sum = React.useMemo<string>(
() => add(firstTerm, secondTerm), // This is where the magic happens
[firstTerm, secondTerm]
);
const handleFirstTermChange = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(
(event) => setFirstTerm(event.target.value),
[]
);
const handleSecondTermChange = React.useCallback<React.ChangeEventHandler<HTMLInputElement>>(
(event) => setSecondTerm(event.target.value),
[]
);
const firstTermId = React.useId();
const secondTermId = React.useId();
const outputId = React.useId();
return (
<form>
<label htmlFor={firstTermId}>First term</label>
<input id={firstTermId} type='number' value={firstTerm} onChange={handleFirstTermChange}/>
<label htmlFor={secondTermId}>Second term</label>
<input id={secondTermId} type='number' value={secondTerm} onChange={handleSecondTermChange}/>
<label htmlFor={outputId}>Sum</label>
<output id={outputId} htmlFor={`${firstTermId} ${secondTermId}`}>{sum}</output>
</form>
);
}In this simple example, this may look like over-engineering. However, most real cases are more complex than this, and that's when this really pays off. We don't want our tests to simulate rendering the same component dozens of times just to check the behaviour of some data conversion algorithm.
This is in fact a general rule of React, and not an internal guideline, but impure components are such a common source of bugs that it should not be unmentioned. A React component should not do any impure operation (for example mutating global data or calling functions with unpredictable output) during the rendering process. A component should behave the same no matter how many times it is rerendered. This is explained in detail in React's article on keeping components pure.
We use CSS modules tied to each component. The file name should match the component name with the suffix .module.css. We aim to separate style-related code from the actual content, so avoid using the style attribute as much as possible. See the "CSS and HTML" section for detailed guidelines on how we use CSS.
- Properties with functions triggered by events should be prefixed with
on, e.g.,onChange. If the property calls a function defined within the same component, it should be namedhandleplus the event name, e.g.,onChange={handleChange}. - Function names should only start with
handlewhen the name describes what triggers the function, not what the function does. For example, a function should not be namedhandleSaveif it performs the actual saving; it should simply be namedsave. However,onChange={save}is acceptable. - A component's properties should not override properties with the same name on child components. For example, if a component contains a button and has a property named
onClick, the button'sonClickfunction must call the parent component'sonClickfunction with the same data. If the button'sonClickfunction is intended to trigger a function on the parent component with different parameters or in more specific cases, the parent component's function must have a different name. - Hooks built on
useQueryhave the suffixQuery. - Hooks built on
useMutationhave the suffixMutation.
We use React Testing Library for testing components.
- Selectors in tests should be chosen according to the priority guidelines.
- All functions used by React components (i.e., functions at the top of the data processing layer) must have their own unit tests.
- We use
toBeInTheDocumentin tests to verify that an element is accessible. (Agetfunction is generally sufficient;toBeInTheDocumentis only to clarify what we are testing.)
Context is a very useful tool in React, but it is important to use it sparingly. While it can save many lines of code, it also makes it harder to see what data a component depends on and where it comes from. We use Context to solve the following problems:
-
Fetching data from a globally available source. This could be data from the Tanstack Query store or data from the user's browser, such as cookies and local storage. The advantage of using
Contextin this case is that the components become independent of these sources and consequently it's easier to mock these data in tests. React Router also falls into this category. In tests dependent on React Router, we should useMemoryRouterto mock parameters from the URL. -
Passing data between compound components. Some components, especially in
@studio/components, are designed to be used together. When such a component depends on data from a parent component,Contextis the most natural solution. -
Internally within large components. In some situations, it may make sense to use
Contextto manage state data in a large component to avoid props drilling. However, this negatively impacts the scalability of child components using this data, so it is important to only do this when the child components are not intended to be reused elsewhere in the solution. In these situations, we should always consider passing the data through props instead.
In all situations not mentioned above, we pass data through props. It can often be tempting to use Context to reduce the number of props, but this does not actually solve much, as the component still has the same dependencies on information. Context just makes it harder to find the source of the information. Also, if a component has many props, it may be a symptom of poorly structured data.
- As far as possible, use React's built-in
onproperties instead of JavaScript'saddEventListener. -
React.Childrenshould not be used. See React's documentation on alternatives. -
React.cloneElementshould not be used. See React's documentation on alternatives.
- Global CSS variables within a package should be prefixed with the package name.
- Visual presentation should generally be implemented with CSS only. For example, use
borderin CSS instead of<hr>(divider) in HTML. This maintains a clear distinction between style and content. - Avoid using
positionin CSS as much as possible. Many positioning issues can be solved usingflexandgrid. - Avoid hardcoding values like colours and sizes. Use CSS variables, preferably ones used in multiple places. An exception here is media queries, where variables are not available.
- Use relative units (such as
remandem) to specify sizes for fonts, icons, and spacing. This ensures sizes follow the user's browser settings. - Use pseudo-classes instead of custom classes and attribute selectors where possible. For example, use
:hoverinstead of using JavaScript to determine whether the mouse is over an element, and:disabledinstead of[disabled]. -
!importantshould only be used as a last resort if it is not possible to increase specificity within reasonable limits.
- We distinguish between two types of class names: Main classes, which describe what something is, and state classes, which describe a state.
- A main class should be a noun, e.g.,
button. - A state class should describe the state, e.g.,
closedorwithValue.
- A main class should be a noun, e.g.,
- All elements with one or more state classes should also have a main class. When using a state class in a CSS selector, we should also reference the main class, e.g.,
.textField.empty(here,textFieldis the main class andemptyis the state class). If we were using the BEM convention, this would correspond to.textField--empty. - We do not use the BEM convention. When using CSS modules combined with the rule above, we solve the same challenges that the BEM convention would address.
- Class names should generally not relate to appearance. For example, avoid class names like
redandwithSpacingOnTop. See the first point for examples of good class names.
We have delibaretely chosen to use plain CSS files with no other tooling than the CSS Modules framework. The reasoning behind this is as follows:
- Modern CSS features make it much easier to work with CSS now than only a few years ago
- There is sufficient browser support for popular features that were previously only available through compile-time tools like Sass and Less
- Developers don't need to learn yet another tool that will probably become obsolete in near future
- Abstracting CSS into own files adheres to the single responsibility principle, since it keeps styling separated from the content, contrary to tools with style-based class names (like Bootstrap and Tailwind) and CSS-in-JS tools (like Styled Components)
In many situations it may look like we need to implement some advanced functionality, while there are already good solutions to the problem available in the browser. In these situations, we should strive to use the native solution. Here are some examples of what this means:
- Use alert, confirm and prompt instead of custom dialogs
- Use
<input type="date"/>instead of implementing a custom date picker or using one from a library (see also The Design System's article on the topic) - Use native validation tools in forms, e.g., reportValidity
- Use pseudo-classes in CSS instead of React state if possible
This has already been a topic of discussion several times within our team, and one reoccuring argument against this approach is the limited possibility to change the design and content of the built-in tools. But this is also an advantage: Looking the same on every single website, they are very predictable, which in the end is what matters the most for the users.
Here are some other considerable advantages:
- We may spend more time on more important things
- Custom-made solutions for these problems are often associated with bugs, which we don't need to worry about when choosing a built-in one
- Being around in the browsers for many years, these solutions are heavily tested by millions of users
- The solutions are supported by assistive technologies and they automatically adapt to the user's browser settings, all of which add to the list of things we don't need to worry about
- The code becomes more clean and simple