Coding Conventions
This is a compendium of the coding conventions we use in O3. The purpose of this document is to help us write code that is consistent and easy to maintain.
Naming
Follow the guidelines in this naming cheatsheet(opens in a new tab).
Use
camelCase
for variables, functions, methods, and class names.Use
kebab-case
for file names and folder names.Components should contain the
.component
suffix in their name (e.g.user.component.tsx
). This nomenclature is used to distinguish components from other files such as resources, stylesheets, and tests, and determines where translation keys and strings should be extracted from. Translation keys and strings will not be extracted from files that do not match this convention.Components may also contain different types of suffixes to indicate their purpose. These custom suffixes should be used in addition to the
.component
suffix. They include:.extension
for components that render extensions e.g. lab-order-basket-panel.extension.tsx(opens in a new tab) for the Lab Order Basket panel extension..modal
for components that render modals e.g. delete-condition.modal.tsx(opens in a new tab) for the Delete Conditions modal..workspace
for components that render forms in the workspace e.g. order-basket.workspace.tsx(opens in a new tab) for the Order Basket workspace.
Unit and integration test files should contain the
.test
suffix in their name (e.g.user.test.tsx
). Do not include the wordcomponent
in the test file name.Playwright e2e tests should contain the '.spec' suffix in their name (e.g.
user.spec.ts
).Stylesheets should not contain
.component
suffix in their name (e.g.user.component.scss
). This is because stylesheets are not components, and are not translated by the translation system. Instead, stylesheets should be named after the component they are styling (e.g.user.scss
).Resource files that encapsulate data fetching logic should contain the
.resource
suffix in their name (e.g.user.resource.ts
). This is to distinguish them from other files such as components, stylesheets, and tests.Name TypeScript files that contain JSX with the
.tsx
extension (e.g.user.component.tsx
). Name TypeScript files that do not contain JSX with the.ts
extension (e.g.user.resource.ts
). In most cases, you shouldn't need to use the.tsx
extension for files outside thesrc
directory.Follow the extension system nomenclature guide when naming your extensions and extension slots.
Use the file name as the component name. For example,
user.component.tsx
should contain a component namedUserComponent
. This makes it easier to find the component in the codebase.Avoid using DOM component prop names for different purposes. For example, avoid using the
className
prop to pass a CSS class name to a component. Instead, use a prop name that is specific to the component, such ascssClass
.Use
camelCase
for prop names. This is consistent with the naming convention for variables, functions, and methods.Translation keys should be in
camelCase
whereas translation strings should be insentence case
. For example,firstName
is a translation key whereasFirst name
is it's corresponding translation string.Frontend modules in monorepos should have names that start with the
esm-
prefix. The name of the module should describe what the module does. For example,esm-user-management
is a good name for a frontend module handling user management concerns.Event handler props should be named after they event they handle
e.g. onClick
for a click handler. By convention, event handler props should start with theon
prefix, followed by a capital letter.State updater functions should be named after the state they update. For example,
setFirstName
is a good name for a state updater function that updates thefirstName
state.What to name your branches is typically down to personal preference. However, when in doubt, name your branches using the conventional commit(opens in a new tab) type that your work conforms to, followed by a slash and a short dash-separated description of the work. Good examples include:
feat/debounced-order-basket-search
,fix/missing-translation
,chore/bump-dependencies
andrefactor/remove-unused-code
.
Project structure
Monorepos should contain domain-specific packages that are related to each other. For example, patient management concerns such as registration and search live in the
openmrs-esm-patient-management
monorepo.Configuration files should generally exist at the top level of the monorepo directory. Notable exceptions to this rule include the a file containing helpers for tests, the
i18next-parser
configuration, andsetupTests.ts
, which should all exist in thetools
directory.Colocate files that are related to each other. For example, a component and its corresponding test and stylesheet should live in the same directory. This way, when you make a change to a component, it's easy to extend that change to the test and stylesheet if necessary.
Avoid placing styles for multiple components in the same stylesheet. Instead, create a separate stylesheet for each component. This makes it easier to find the styles for a particular component.
Components
Don't keep unused code in your components. Keeping dead code around can cause confusion and makes it harder to maintain the codebase.
Validate the props passed to your component using type aliases or interfaces. This helps to catch bugs early and makes it easier to understand how the component is used.
Make sure you read through the Component API for a particular Carbon component before using it. This helps you to understand the component's props and how to use them. It also helps you to understand the component's behavior and can obviate the need for writing custom code. For example, here's the Component API for the Button component(opens in a new tab).
Use keys in lists(opens in a new tab). This helps React to identify which items have changed, been added, or been removed. This is especially important if you are rendering a list of components that contain state.
Generate keys from the data itself(opens in a new tab) if possible. For example, if you are rendering a list of patients from the database, use the patient's ID as the key. This ensures that the key is unique and stable across renders.
Avoid using effects(opens in a new tab) for things that don't involve synchronizing with external systems. The distinction is nuanced and can be difficult to understand. Please read and internalize the linked article before reaching for effects.
Don't reach for performance optimizations like memo, useMemo and useCallback before you need them. These optimizations come at a cost, and can make your code harder to understand. Read this article(opens in a new tab) and this article(opens in a new tab) to understand when to use these hooks.
Omit the value of a prop when it is explicitly
true
. For example,<UserComponent isAdmin />
is preferred over<UserComponent isAdmin={true} />
.Follow consistent code formatting, naming conventions and folder structure. This makes the codebase more readable and easier to maintain.
Data fetching
Colocate your data fetching logic in a file suffixed with
.resource
. For example,user.resource.ts
contains the data fetching logic for the user resource.Wherever possible, prefer abstracting your data fetching into a custom hook rather than fetching with effects(opens in a new tab). Fetching data with effects has many downsides(opens in a new tab) and should be avoided. Instead, prefer using SWR(opens in a new tab) hooks.
Use SWR(opens in a new tab) hooks to fetch data from the backend. Use SWRImmutable for resources that are not expected to change often, such as backend configurations.
Put the SWR hook in a separate file, and export it as a function. This allows us to reuse the same hook in multiple components.
Memoize the return value of your SWR hook using
useMemo
to prevent unnecessary rerenders. This is especially important if the hook is used in a component that is rendered many times, such as a table row.Data fetching hooks should follow the naming convention
use<resource>
. For example,useUser
is the hook for fetching user data.Use
openmrsFetch
to fetch data from the backend. openmrsFetch is a wrapper around thefetch
API that adds authentication and authorization headers and handles errors. Pass it touseSWR
as thefetcher
argument of your SWR hooks.Use
openmrsObservableFetch
only if you need to fetch data from the backend using an observable. This is useful for streaming data from the backend. Ensure you understand the difference between observables and promises before reaching for this function.
Internationalization
Do not manually edit any of the locale-speficic translation files in the
translations
directory. Run theextract-translations
script instead to keep theen.json
file in sync with the translation keys in the codebase.Use the useTranslation(opens in a new tab) hook to translate strings in your components.
Use the Trans(opens in a new tab) component to translate strings that contain HTML tags.
To handle pluralization, use the following pattern:
// If there's only one risk flag, the string "1 risk flag" is displayed.// If there are multiple risk flags, the string "{count} risk flags" is displayed// e.g. "3 risk flags".<span className={styles.flagText}> {t("flagCount", { count: riskFlags.length, })}</span>
The corresponding keys and strings for the code above should look like this:
"flagCount_one": "{{ count }} risk flag","flagCount_other": "{{ count }} risk flags"
State management
Follow the guidelines outlined here(opens in a new tab).
To share state between components, lift the state up to the nearest common ancestor(opens in a new tab) of the components that need to share the state and pass the state down to the components as props. This is the simplest way to share state between components.
Avoid creating state variables for things that can be computed from existing state variables(opens in a new tab). For example, if you have a state variable called
firstName
and another calledlastName
, don't create a third state variable calledfullName
. Instead, derive thefullName
from thefirstName
andlastName
state variables.
Mutations and side effects
Use SWR's global and bound mutate(opens in a new tab) APIs to mutate data in the cache. This ensures that the cache is updated consistently across the application and omits the need to reload the page to see the changes.
Show a toast notification when a mutation succeeds. When a mutation fails, show a inline notification with an error message that communicates the reason for the failure.
Event handlers
Ensure that your event handlers properly define their cleanup logic. Event handlers in useEffect callbacks should always return a cleanup function to prevent unexpected behaviour. For example, do this:
useEffect(() => { const handleClick = () => { // ... handle the click event }; document.addEventListener("click", handleClick); return () => { document.removeEventListener("click", handleClick); };}, []);
Instead of this:
Type annotations
Follow the guidelines outlined in React TypeScript Cheatsheets(opens in a new tab).
Always annotate your function parameters with types. This makes it easier to understand what the function does, and explicitly expresses the function's contracts.
Rely on TypeScript's type inference for things like variable and array initialization, and in some cases, function return types. The goal of the type system is not to annotate every single variable with a type, but rather to make sure that the important parts of your code are type-safe. Read more about type inference here(opens in a new tab).
TypeScript
interfaces
enable declaration merging and can be extended by other interfaces. This makes them more flexible thantype
aliases, which cannot be extended. If you don't need these features, prefer usingtype
aliases instead.Don't use
any
unless you absolutely have to. Instead, useunknown
ornever
to express the fact that you don't know the type of a variable or that a function never returns.Wherever possible, use the
import type
syntax when importing types. This prevents the type from being imported at runtime, which reduces the bundle size. For example:Prefer union types over status enums(opens in a new tab). For example, prefer
type Status = "loading" | "error" | "success"
overenum Status { Loading, Error, Success }
. This is because enums are not type safe, and can be assigned any value. For example,Status.Loading = "error"
is a valid statement, butStatus = "error"
is not.Use the
jest.mocked
utility to preserve type information when mocking functions in tests. For example:Prefer:
Over:
Styling
Be wary of using global styles. They can easily lead to unintended side effects and make it difficult to reason about the codebase. Nest global styles under a class name to prevent them from affecting other components.
Put Carbon style overrides in overrides.scss(opens in a new tab). This ensures that the overrides are applied consistently across the application.
Prefer using Carbon color(opens in a new tab), spacing(opens in a new tab) and type(opens in a new tab) tokens over hard-coded values. Below are some examples of using tokens in code:
Find a useful reference for color token mappings here(opens in a new tab).
Use SASS features(opens in a new tab) like interpolation, at-rules, mixins, and functions to make your styles more reusable and maintainable.
If you want to apply styles based on the user's viewport size, use our predefined breakpoints(opens in a new tab). For example, to apply different styles for tablet and desktop viewports, do this:
ℹ️
Make sure to scope your styles under a class name (such as
.form
in the example above) to avoid them affecting other components.Use the classnames(opens in a new tab) library to conditionally apply styles to an element. Consider using classnames if you're interpolating multiple class names into a string. For example, the following snippet:
Could be replaced by:
The following snippet shows a more advanced case - a
div
styled with multiple conditional styles:You can refactor this snippet to leverage
classnames
as follows:
Search inputs
Debounce search inputs to prevent unnecessary requests to the backend. Use the useDebounce(opens in a new tab) hook to debounce search inputs. Here's a snippet (some bits are omitted for brevity) showing how you could use the hook:
Use fuzzy(opens in a new tab) to implement fuzzy search. Fuzzy search is a strategy for matching search terms that are similar to, but not exactly the same as, the search term. For example, if the search term is
John
, fuzzy search will matchJon
,Jhon
, andJohhn
. This is useful for matching search terms that are misspelled or contain typos. Here's how we can leverage fuzzy to enhance the search experience from the snippet above:We're using the
debouncedSearchTerm
from the snippet above to filter the list of forms. We're also using theextract
option to tell fuzzy how to extract the search term from the form. In this case, we're extracting the search term from the form's name and version. This is because we want to match forms that contain the search term in their name or version. Finally, we're sorting the results by score, which is a measure of how closely the search term matches the form.
Testing
Avoid testing implementation details. Instead, test the component's public API. This makes it easier to refactor the component without having to rewrite the tests.
Follow the guidelines outlined here(opens in a new tab).
Don't make these common testing mistakes(opens in a new tab).
Structure large test suites using object page models(opens in a new tab) when writing e2e tests using Playwright.
Follow the e2e testing best practices(opens in a new tab) outlined in the Playwright docs.
Functions from
@openmrs/esm-framework
get mocked(opens in a new tab) automatically when running tests. To override the default mock, use thejest.mock
API. For example:Remember to update the mock when you modify or add a new function to
@openmrs/esm-framework
.