TS-41: React
This technical standard covers best practices for working with React, a popular JavaScript library used for composing graphical user interfaces from reusable components. The guidance here is written for React on the web, but the component, state, and filesystem conventions apply to React’s other render targets too. See Cross-platform React for sharing one codebase between a web application and native mobile applications.
Importing React
From React v17, the explicit import of React is no longer required.
import React from "react"
Hooks and other utilities can be imported from the react package individually.
/* React ≤ v17 */
import React, { useState } from "react"
/* React ≥ v18 */
import { useState } from "react"
/* Usage: */
const [things, setThings] = useState([])But it is RECOMMENDED to import the entire react package, and use the React namespace for all React-related
imports. This makes it easier to identify React-related code in your project.
import React from "react" /* Usage: */ const [things, setThings] = React.useState([])
For this reason, the import React from "react" pattern is still RECOMMENDED for all React components.
Wrapping native elements
It is RECOMMENDED to use the spread operator to pass all props from React components to the underlying native elements they wrap.
import React from "react"
export default function Button(props) {
return (
<button {...props}>
{props.children}
</button>
)
}This allows users of the component to set any number of native HTML attributes as props. This pattern means that consumers could pass invalid props, which are not supported by the abstracted native element. However, since HTML will just ignore invalid attributes, this will not cause any runtime issues. It’s a good trade-off for the increased flexibility.
An improvement to this pattern is to use object destructuring on props. This allows you to cherry pick props that
fulfill component-specific roles, while passing the rest of the props to the underlying native element.
import React from "react"
export default function Button({children, className, size, ...rest}) {
return (
let sizeClass
if (size === "sm") sizeClass = "button-small"
if (size === "lg") sizeClass = "button-large"
<button className={`${sizeClass} ${className}`} {...rest}>
{children}
</button>
)
}This pattern simplifies the code (components don’t need to explicitly plan for every possible attribute that a native HTML element supports) and increases reusability (you can use the React components for every use case you would be able to if you were writing pure HTML).
This pattern is restricted to wrappers for single native HTML elements. It is NOT RECOMMENDED to use this pattern for custom components, composed of multiple native elements and/or other components. In this case, the props are the part of the component’s declarative API, and should be explicitly defined.
State
State setters in React SHOULD NOT directly pass new state values, but SHOULD instead use the callback API, which receives the current state value as its argument and which is expected to return the new state value. Although this is more verbose, it is best practice because it ensures that the new state value is based on the most recent state value.
export default function App() {
const [count, setCount] = React.useState(0)
/* No: */
function add() {
setCount(count++)
}
/* Yes: */
function subtract() {
setCount(prevCount => prevCount - 1)
}
return (
<div className="counter">
<button className="counter--minus" onClick={subtract}>–</button>
<div className="counter--count">
<h1>{count}</h1>
</div>
<button className="counter--plus" onClick={add}>+</button>
</div>
)
}Filesystem
React components should be organized in a way that reflects the filesystem structure of the project. This means that components should be placed in directories that correspond to the component hierarchy.
File extensions
Since JSX is not standard JavaScript, source files that contain JSX MUST use the .jsx file extension rather than
.js. Likewise, TypeScript source files that contain JSX MUST use the .tsx extension rather than .ts.
Note
Compilers do not distinguish between .js and .jsx (or between .ts and .tsx) — the extension is a
convention for the benefit of people reading the code, not a requirement of the toolchain. Using the JSX-specific
extension makes it immediately clear which files contain JSX syntax.
Filesystem structure
The following filesystem structure is RECOMMENDED as a starting point. In this design:
- Elements are the smallest building blocks of the application, such as buttons, form fields, and icons. This category also includes larger composite elements that are still generic and reusable, such as a calendar, data table, or modal dialog.
- Features are libraries of domain-oriented components that are specific to the application, such as payment management, transaction history, and user authentication. Feature components are typically composed of multiple elements and may include their own state management, data fetching, and business logic.
- Views are the top-level components that represent the different screens or views of the application. They are composed of multiple features and elements, and are responsible for rendering the overall layout and structure of the application. They should not contain any domain-specific logic, which should be encapsulated within the features. But they may contain UI logic, such as routing and navigation.
src/ ├── assets/ ├── elements/ │ ├── button/ │ └── calendar/ │ ├── components/ │ ├── types/ │ └── utils/ ├── features/ │ ├── payments/ │ │ ├── components/ │ │ │ ├── PaymentForm.tsx │ │ │ ├── PaymentList.tsx │ │ │ └── PaymentDetails.tsx │ │ ├── hooks/ │ │ ├── mutations/ │ │ ├── queries/ │ │ ├── services/ │ │ ├── types/ │ │ ├── utils/ │ │ └── index.ts │ ├── transactions/ │ │ ├── components/ │ │ ├── ... │ │ └── index.ts │ └── auth/ │ ├── components/ │ ├── queries/ │ └── index.ts ├── views/ │ ├── components/ │ ├── queries/ │ ├── mutations/ │ ├── hooks/ │ ├── services/ │ ├── types/ │ └── utils/ └──tests/
Tests MAY be colocated with the components they test (for purer modularity), or placed in a separate tests/
directory. Generally, collocating tests is RECOMMENDED for large, complex projects, while a dedicated top-level
tests/ directory tends to work well only for smaller projects.
Category | File/Folder naming | Examples |
|---|---|---|
API | camelCase |
|
Queries | camelCase |
|
Components | PascalCase |
|
Constants | kebab-case |
|
Contexts | PascalCase |
|
Hooks | camelCase |
|
Views | PascalCase |
|
Utils | kebab-case |
|
Types | kebab-case |
|
Cross-platform React
React is not tied to the DOM. The component model, the hooks API, and the state conventions described elsewhere in this standard hold equally under React Native, which renders the same components to the native view hierarchies of iOS and Android, and under React Native for Web, which renders those components back to the DOM.
Sharing one codebase across platforms
Where one product ships as a web application and as native mobile applications, a single React codebase covering all three targets SHOULD be preferred over a separate codebase per platform.
Two codebases for one product are two products. Every feature is implemented twice, every defect is debugged twice, and the two implementations drift into behaviors that no specification records. Bluesky’s website, iOS app, and Android app were built from one React Native and Expo codebase by a single engineer over roughly a year, before the client team grew to six people. That is a rate of delivery a per-platform split forecloses.
React Native with Expo is the RECOMMENDED basis for a shared codebase. Expo supplies the build, distribution, and over-the-air update toolchain, and wires up React Native for Web as its web target, so the browser client is another output of the same component tree rather than a parallel implementation of it.
Note
Sharing a codebase does not make the extra platforms free. Targeting iOS, Android, and the web at once carries the same class of cost as targeting several browsers, and the platform layers diverge further than browsers do. Budget for per-platform defects, per-platform release processes, and app store review, even where the component tree is shared.
Isolating platform-specific code
Platform differences that a shared component cannot absorb MUST be isolated at a module boundary, rather than
spread through shared code as Platform.OS branches. Bundlers resolve a platform-suffixed file extension ahead of
the bare one, so a single import picks up a different implementation per target.
elements/
└── share-sheet/
├── ShareSheet.tsx
├── ShareSheet.ios.tsx
├── ShareSheet.android.tsx
└── ShareSheet.web.tsxHere ShareSheet.tsx holds the shared types and the fallback implementation, and each suffixed file replaces it on
the platform it names. Every variant MUST export the same API, so that code consuming the module never has to test
which platform it is running on.
Platform.select is appropriate for a single value that differs by platform — a shadow style, a hit-slop constant,
a default font family. It is NOT RECOMMENDED for branching a whole render tree, which is what a platform-suffixed
module is for.
import { Platform, StyleSheet } from "react-native"
const styles = StyleSheet.create({
card: {
...Platform.select({
ios: { shadowOpacity: 0.2, shadowRadius: 4 },
android: { elevation: 4 },
web: { boxShadow: "0 1px 4px rgba(0, 0, 0, 0.2)" },
}),
},
})Web-only products
A product with no native mobile target MUST NOT adopt React Native speculatively, on the expectation that mobile apps might be wanted later.
React Native’s primitives are not semantic HTML. View and Text render to div and span, so headings,
landmarks, lists, and form semantics all have to be rebuilt deliberately on top of primitives that carry none of
them. Each one missed costs accessibility, per TS-39: HTML, and search engine visibility, per
TS-19: Search engine optimization (SEO).
For a web-only product, use React against the DOM directly, and follow TS-18: Web GUIs. Adding a native mobile target later is a real cost, but it is paid once, against a codebase whose component boundaries are already established. Paying it up front, on a product that never ships a mobile app, buys nothing.
References
- The Pragmatic Engineer (2024). Inside Bluesky’s Engineering Culture. — The single-codebase argument, and the React Native and Expo experience report, used in Cross-platform React.