This style guide is a collection of recommendations for writing code that is easy to understand, maintain, and scale.
It goes hand-in-hand with the Epic Programming Principles and the Epic Web Config.
This is an opinionated style guide that's most useful for people who:
- Don't have a lot of experience writing code and want some guidance on how to write code that's easy to understand, maintain, and scale.
- Have experience writing code but want a set of standards to align on for working in a team.
Much of this is subjective, but most opinions are thought through and based on years of experience working with large codebases and teams.
Note: Not every possible formatting opinion is mentioned because they are handled automatically by Prettier anyway.
This section will include TypeScript guidelines as well.
Use const
by default. Only use let
when you need to reassign. Never use
var
.
Remember that const
does not mean "constant" in the sense of "unchangeable".
It means "constant reference". So if the value is an object, you can still
change the properties of the object.
Use descriptive, clear names that explain the value's purpose. Avoid single-letter names except in small loops or reducers where the value is obvious from context.
// ✅ Good
const workshopTitle = 'Web App Fundamentals'
const instructorName = 'Kent C. Dodds'
const isEnabled = true
const sum = numbers.reduce((total, n) => total + n, 0)
const names = people.map((p) => p.name)
// ❌ Avoid
const t = 'Web App Fundamentals'
const n = 'Kent C. Dodds'
const e = true
Follow the naming cheatsheet by Artem Zakharchenko for more specifics on naming conventions.
For truly constant values used across files, use uppercase with underscores:
const BASE_URL = 'https://epicweb.dev'
const DEFAULT_PORT = 3000
Use object literal syntax for creating objects. Use property shorthand when the property name matches the variable name.
// ✅ Good
const name = 'Kent'
const age = 36
const person = { name, age }
// ❌ Avoid
const name = 'Kent'
const age = 36
const person = { name: name, age: age }
Use computed property names when creating objects with dynamic property names.
// ✅ Good
const key = 'name'
const obj = {
[key]: 'Kent',
}
// ❌ Avoid
const key = 'name'
const obj = {}
obj[key] = 'Kent'
Use object method shorthand:
// ✅ Good
const obj = {
method() {
// ...
},
async asyncMethod() {
// ...
},
}
// ❌ Avoid
const obj = {
method: function () {
// ...
},
asyncMethod: async function () {
// ...
},
}
Note: Ordering of properties is not important (and not specified by the spec) and it's not a priority for this style guide either.
Don't use them. When I do this:
console.log(person.name)
person.name = 'Bob'
All I expect to happen is to get the person's name and pass it to the log
function and to set the person's name to 'Bob'
.
Once you start using property accessors (getters and setters) then those guarantees are off.
// ✅ Good
const person = {
name: 'Hannah',
}
// ❌ Avoid
const person = {
get name() {
// haha! Now I can do something more than just return the name! 😈
return this.name
},
set name(value) {
// haha! Now I can do something more than just set the name! 😈
this.name = value
},
}
This violates the principle of least surprise.
Use Array literal syntax for creating arrays.
// ✅ Good
const items = [1, 2, 3]
// ❌ Avoid
const items = new Array(1, 2, 3)
Use .filter(Boolean)
to remove falsey values from an array.
// ✅ Good
const items = [1, null, 2, undefined, 3]
const filteredItems = items.filter(Boolean)
// ❌ Avoid
const filteredItems = items.filter(
(item) => item !== null && item !== undefined,
)
Use Array methods over loops when transforming arrays with pure functions. Use
for
loops when imperative code is necessary. Never use forEach
because it's
never more readable than a for
loop and there's not situation where the
forEach
callback function could be pure and useful. Prefer for...of
over
for
loops.
// ✅ Good
const items = [1, 2, 3]
const doubledItems = items.map((n) => n * 2)
// ❌ Avoid
const doubledItems = []
for (const n of items) {
doubledItems.push(n * 2)
}
// ✅ Good
for (const n of items) {
// ...
}
// ❌ Avoid
for (let i = 0; i < items.length; i++) {
const n = items[i]
// ...
}
// ❌ Avoid
items.forEach((n) => {
// ...
})
// ✅ Good
for (const [i, n] of items.entries()) {
console.log(`${n} at index ${i}`)
}
// ❌ Avoid
for (const n of items) {
const i = items.indexOf(n)
console.log(`${n} at index ${i}`)
}
Favor simple .filter
and .map
chains over complex .reduce
callbacks unless
performance is an issue.
// ✅ Good
const items = [1, 2, 3, 4, 5]
const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2)
// ❌ Avoid
const doubledItems = items.reduce((acc, n) => {
acc.push(n * 2)
return acc
}, [])
Prefer the spread operator to copy an array:
// ✅ Good
const itemsCopy = [...items]
const combined = [...array1, ...array2]
// ❌ Avoid
const itemsCopy = items.slice()
const combined = array1.concat(array2)
Prefer non-mutative array methods like toReversed()
, toSorted()
, and
toSpliced()
when available. Otherwise, create a new array. Unless performance
is an issue or the original array is not referenced (as in a chain of method
calls).
// ✅ Good
const reversedItems = items.toReversed()
const mappedFilteredSortedItems = items
.filter((n) => n > 2)
.map((n) => n * 2)
.sort((a, b) => a - b)
// ❌ Avoid
const reversedItems = items.reverse()
Use with
to create a new object with some properties replaced.
// ✅ Good
const people = [{ name: 'Kent' }, { name: 'Sarah' }]
const personIndex = 0
const peopleWithKentReplaced = people.with(personIndex, { name: 'John' })
// ❌ Avoid (mutative)
const peopleWithKentReplaced = [...people]
peopleWithKentReplaced[personIndex] = { name: 'John' }
Prefer the Array generic syntax over brackets for TypeScript types:
// ✅ Good
const items: Array<string> = []
function transform(numbers: Array<number>) {}
// ❌ Avoid
const items: string[] = []
function transform(numbers: number[]) {}
Learn more about the reasoning behind the Array generic syntax in the Array Types in TypeScript article by Dominik Dorfmeister.
Use destructuring to make your code more terse.
// ✅ Good
const { name, avatar, 𝕏: xHandle } = instructor
const [first, second] = items
// ❌ Avoid
const name = instructor.name
const avatar = instructor.avatar
const xHandle = instructor.𝕏
Destructuring multiple levels is fine when formatted properly by a formatter, but can definitely get out of hand, so use your best judgement. As usual, try both and choose the one you hate the least.
// ✅ Good (nesting, but still readable)
const {
name,
avatar,
𝕏: xHandle,
address: [{ city, state, country }],
} = instructor
// ❌ Avoid (too much nesting)
const [
{
name,
avatar,
𝕏: xHandle,
address: [
{
city: {
latitude: firstCityLatitude,
longitude: firstCityLongitude,
label: firstCityLabel,
},
state: { label: firstStateLabel },
country: { label: firstCountryLabel },
},
],
},
] = instructor
Prefer template literals over string concatenation.
// ✅ Good
const name = 'Kent'
const greeting = `Hello ${name}`
// ❌ Avoid
const greeting = 'Hello ' + name
Use template literals for multi-line strings.
// ✅ Good
const html = `
<div>
<h1>Hello</h1>
</div>
`.trim()
// ❌ Avoid
const html = '<div>' + '\n' + '<h1>Hello</h1>' + '\n' + '</div>'
Use function declarations over function expressions. Name your functions descriptively.
This is important because it allows the function definition to be hoisted to the top of the block, which means it's callable anywhere which frees your mind to think about other things.
// ✅ Good
function calculateTotal(items: Array<number>) {
return items.reduce((sum, item) => sum + item, 0)
}
// ❌ Avoid
const calculateTotal = function (items: Array<number>) {
return items.reduce((sum, item) => sum + item, 0)
}
const calculateTotal = (items: Array<number>) =>
items.reduce((sum, item) => sum + item, 0)
Limit creating single-use functions. By taking a large function and breaking it down into many smaller functions, you reduce benefits of type inference and have to define types for each function and make additional decisions about the number and format of arguments. Instead, extract logic only when it needs to be reused or when a portion of the logic is clearly part of a unique concern.
// ✅ Good
function doStuff() {
// thing 1
// ...
// thing 2
// ...
// thing 3
// ...
// thing N
}
// ❌ Avoid
function doThing1(param1: string, param2: number) {}
function doThing2(param1: boolean, param2: User) {}
function doThing3(param1: string, param2: Array<User>, param3: User) {}
function doThing4(param1: User) {}
function doStuff() {
doThing1()
// ...
doThing2()
// ...
doThing3()
// ...
doThing4()
}
Prefer default parameters over short-circuiting.
// ✅ Good
function createUser(name: string, role = 'user') {
return { name, role }
}
// ❌ Avoid
function createUser(name: string, role: string) {
role ??= 'user'
return { name, role }
}
Return early to avoid deep nesting. Use guard clauses:
// ✅ Good
function getMinResolutionValue(resolution: number | undefined) {
if (!resolution) return undefined
if (resolution <= 480) return MinResolution.noLessThan480p
if (resolution <= 540) return MinResolution.noLessThan540p
return MinResolution.noLessThan1080p
}
// ❌ Avoid
function getMinResolutionValue(resolution: number | undefined) {
if (resolution) {
if (resolution <= 480) {
return MinResolution.noLessThan480p
} else if (resolution <= 540) {
return MinResolution.noLessThan540p
} else {
return MinResolution.noLessThan1080p
}
} else {
return undefined
}
}
Prefer async/await over promise chains:
// ✅ Good
async function fetchUserData(userId: string) {
const user = await getUser(userId)
const posts = await getUserPosts(user.id)
return { user, posts }
}
// ✅ Fine, because wrapping in try/catch is annoying
function sendAnalytics(event: string) {
return fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ event }),
}).catch(() => null)
}
// ❌ Avoid
function fetchUserData(userId: string) {
return getUser(userId).then((user) => {
return getUserPosts(user.id).then((posts) => ({ user, posts }))
})
}
// ❌ Avoid
async function sendAnalytics(event: string) {
try {
return await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ event }),
})
} catch {
// ignore
return null
}
}
Anonymous inline callbacks should be arrow functions:
// ✅ Good
const items = [1, 2, 3]
const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2)
// ❌ Avoid
const items = [1, 2, 3]
const doubledGreaterThanTwoItems = items
.filter(function (n) {
return n > 2
})
.map(function (n) {
return n * 2
})
Arrow functions should include parentheses even with a single parameter:
// ✅ Good
const items = [1, 2, 3]
const doubledGreaterThanTwoItems = items.filter((n) => n > 2).map((n) => n * 2)
// ❌ Avoid
const items = [1, 2, 3]
const doubledGreaterThanTwoItems = items.filter(n => n > 2).map(n => n * 2)
This makes it easier to add/remove parameters without having to futz around with parentheses.
In general, files that change together should be located close to each other. In Breaking a single file into multiple files should be avoided unless absolutely necessary.
Specifics around file structure depends on a multitude of factors:
- Framework conventions
- Project size
- Team size
Strive to keep the file structure as flat as possible.
Framework and other tool conventions sometimes require default exports, but prefer named exports in all other cases.
// ✅ Good
export function add(a: number, b: number) {
return a + b
}
export function subtract(a: number, b: number) {
return a - b
}
// ❌ Avoid
export default function add(a: number, b: number) {
return a + b
}
Do not use barrel files. If you don't know what they are, good. If you do and you like them, it's probably because you haven't experienced their issues just yet, but you will. Just avoid them.
In general, strive to keep modules pure (read more about this in Pure Modules). This will make your application start faster and be easier to understand and test.
// ✅ Good
let serverData
export function init(a: number, b: number) {
const el = document.getElementById('server-data')
const json = el.textContent
serverData = JSON.parse(json)
}
export function getServerData() {
if (!serverData) throw new Error('Server data not initialized')
return serverData
}
// ❌ Avoid
let serverData
const el = document.getElementById('server-data')
const json = el.textContent
export const serverData = JSON.parse(json)
Note: In practice, you can't avoid some modules having side-effects (you gotta kick off the app somewhere), but most modules should be pure.
Import order has semantic meaning (modules are executed in the order they're imported), but if you keep most modules pure, then order shouldn't matter. For this reason, having your imports grouped can make things a bit easier to read.
// Group imports in this order:
import 'node:fs' // Built-in
import 'match-sorter' // external packages
import '#app/components' // Internal absolute imports
import '../other-folder' // Internal relative imports
import './local-file' // Local imports
Each module imported should have a single import statement:
// ✅ Good
import { type MatchSorterOptions, matchSorter } from 'match-sorter'
// ❌ Avoid
import { type MatchSorterOptions } from 'match-sorter'
import { matchSorter } from 'match-sorter'
All static imports are executed at the top of the file so they should appear there as well to avoid confusion.
// ✅ Good
import { matchSorter } from 'match-sorter'
function doStuff() {
// ...
}
// ❌ Avoid
function doStuff() {
// ...
}
import { matchSorter } from 'match-sorter'
All exports should be inline with the function/type/etc they are exporting. This avoids duplication of the export identifier and having to keep it updated when changing the name of the exported thing.
// ✅ Good
export function add(a: number, b: number) {
return a + b
}
// ❌ Avoid
function add(a: number, b: number) {
return a + b
}
export { add }
Use ECMAScript modules for everything. The age of CommonJS is over.
✅ Good package.json:
{
"type": "module"
}
Use exports field in package.json to explicitly declare module entry points.
✅ Good package.json:
{
"exports": {
"./utils": "./src/utils.js"
}
}
Use import aliases to avoid long relative paths. Use the standard imports
config field in package.json to declare import aliases.
✅ Good package.json:
{
"imports": {
"#app/*": "./app/*",
"#tests/*": "./tests/*"
}
}
import { add } from '#app/utils/math.ts'
Note: Latest versions of TypeScript support this syntax natively.
The ECMAScript module spec requires file extensions to be included in import
paths. Even though TypeScript doesn't require it, always include the file
extension in your imports. An exception to this is when importing a module which
has exports
defined in its package.json.
// ✅ Good
import { redirect } from 'react-router'
import { add } from './math.ts'
// ❌ Avoid
import { add } from './math'
When accessing properties on objects, use dot-notation unless you can't syntactically (like if it's dynamic or uses special characters).
const user = { name: 'Brittany', 'data-id': '123' }
// ✅ Good
const name = user.name
const id = user['data-id']
function getUserProperty(user: User, property: string) {
return user[property]
}
// ❌ Avoid
const name = user['name']
Use triple equals (===
and !==
) for comparisons. This will ensure you're not
falling prey to type coercion.
That said, when comparing against null
or undefined
, using double equals
(==
and !=
) is just fine.
// ✅ Good
const user = { id: '123' }
if (user.id === '123') {
// ...
}
const a = null
if (a === null) {
// ...
}
if (b != null) {
// ...
}
// ❌ Avoid
if (a == null) {
// ...
}
if (b !== null && b !== undefined) {
// ...
}
Rely on truthiness instead of comparison operators.
// ✅ Good
if (user) {
// ...
}
// ❌ Avoid
if (user === true) {
// ...
}
Using braces in switch statements is recommended because it helps clarify the scope of each case and it avoids variable declarations from leaking into other cases.
// ✅ Good
switch (action.type) {
case 'add': {
const { amount } = action
add(amount)
break
}
case 'remove': {
const { removal } = action
remove(removal)
break
}
}
// ❌ Avoid
switch (action.type) {
case 'add':
const { amount } = action
add(amount)
break
case 'remove':
const { removal } = action
remove(removal)
break
}
// ✅ Good
const isAdmin = user.role === 'admin'
const value = input ?? defaultValue
// ❌ Avoid
const isAdmin = user.role === 'admin' ? true : false
const value = input != null ? input : defaultValue
Use braces for multi-line blocks even when the block is the body of a single statement.
// ✅ Good
if (!user) return
if (user.role === 'admin') {
abilities = ['add', 'remove', 'edit', 'create', 'modify', 'fly', 'sing']
}
// ❌ Avoid
if (user.role === 'admin')
abilities = ['add', 'remove', 'edit', 'create', 'modify', 'fly', 'sing']
Unless you're using the value of the condition in an expression, prefer using statements instead of expressions.
// ✅ Good
if (user) {
makeUserHappy(user)
}
// ❌ Avoid
user && makeUserHappy(user)
Comments should explain why something is done a certain way, not what the code does. The names you use for variables and functions are "self-documenting" in a sense that they explain what the code does. But if you're doing something in a way that's non-obvious, comments can be helpful.
// ✅ Good
// We need to sanitize lineNumber to prevent malicious use on win32
// via: https://example.com/link-to-issue-or-something
if (lineNumber && !(Number.isInteger(lineNumber) && lineNumber > 0)) {
return { status: 'error', message: 'lineNumber must be a positive integer' }
}
// ❌ Avoid
// Check if lineNumber is valid
if (lineNumber && !(Number.isInteger(lineNumber) && lineNumber > 0)) {
return { status: 'error', message: 'lineNumber must be a positive integer' }
}
Use TODO comments to mark code that needs future attention or improvement.
// ✅ Good
// TODO: figure out how to send error messages as JSX from here...
function getErrorMessage() {
// ...
}
// ❌ Avoid
// FIXME: this is broken
function getErrorMessage() {
// ...
}
Use FIXME comments to mark code that needs immediate attention or improvement.
// ✅ Good
// FIXME: this is broken
function getErrorMessage() {
// ...
}
Note: The linter should lint against FIXME comments, so this is useful if you are testing things out and want to make sure you don't accidentally commit your work in progress.
When you need to work around TypeScript limitations (or your own knowledge gaps
with TypeScript), use @ts-expect-error
with a comment explaining why.
// ✅ Good
// @ts-expect-error no idea why this started being an issue suddenly 🤷♂️
if (jsxEl.name !== 'EpicVideo') return
// ❌ Avoid
// @ts-ignore
if (jsxEl.name !== 'EpicVideo') return
Use JSDoc comments for documenting public APIs and their types.
// ✅ Good
/**
* This function generates a TOTP code from a configuration
* and this comment will explain a few things that are important for you to
* understand if you're using this function
*
* @param {OTPConfig} config - The configuration for the TOTP
* @returns {string} The TOTP code
*/
export function generateTOTP(config: OTPConfig) {
// ...
}
Don't add comments that just repeat what the code already clearly expresses.
// ✅ Good
function calculateTotal(items: Array<number>) {
return items.reduce((sum, item) => sum + item, 0)
}
// ❌ Avoid
// This function calculates the total of all items in the array
function calculateTotal(items: Array<number>) {
return items.reduce((sum, item) => sum + item, 0)
}
Don't use semicolons. The rules for when you should use semicolons are more
complicated than the rules for when you must use semicolons. With the right
eslint rule
(no-unexpected-multiline
)
and a formatter that will format your code funny for you if you mess up, you can
avoid the pitfalls. Read more about this in
Semicolons in JavaScript: A preference.
// ✅ Good
const name = 'Kent'
const age = 36
const person = { name, age }
const getPersonAge = () => person.age
function getPersonName() {
return person.name
}
// ❌ Avoid
const name = 'Kent';
const age = 36;
const person = { name, age };
const getPersonAge = () => person.age;
function getPersonName() {
return person.name
}
The only time you need semicolons is when you have a statement that starts with
(
, [
, or `
. Instances where you do that are few and far between. You
can prefix that with a ;
if you need to and a code formatter will format your
code funny if you forget to do so (and the linter rule will bug you about it
too).
// ✅ Good
const name = 'Kent'
const age = 36
const person = { name, age }
// The formatter will add semicolons here automatically
;(async () => {
const result = await fetch('/api/user')
return result.json()
})()
// ❌ Avoid
const name = 'Kent'
const age = 36
const person = { name, age }
// Don't manually add semicolons
;(async () => {
const result = await fetch('/api/user')
return result.json()
})()
Let TypeScript do the heavy lifting with type inference when possible:
// ✅ Good
function add(a: number, b: number) {
return a + b // TypeScript infers return type as number
}
// ❌ Avoid
function add(a: number, b: number): number {
return a + b
}
Use generics to create reusable components and functions. And treat type names in generics the same way you treat any other kind of variable or parameter (because a generic type is basically a parameter!):
// ✅ Good
function createArray<Value>(length: number, value: Value): Array<Value> {
return Array(length).fill(value)
}
// ❌ Avoid
function createStringArray(length: number, value: string) {
return Array(length).fill(value)
}
Avoid type assertions (as
) when possible. Instead, use type guards or runtime
validation.
// ✅ Good
function isError(maybeError: unknown): maybeError is Error {
return (
maybeError &&
typeof maybeError === 'object' &&
'message' in maybeError &&
typeof maybeError.message === 'string'
)
}
// ❌ Avoid
const error = caughtError as Error
Use type guards to narrow types and provide runtime type safety. Type guards are functions that check if a value is of a specific type. The most common way to create a type guard is using a type predicate.
// ✅ Good - Using type predicate
function isError(maybeError: unknown): maybeError is Error {
return (
maybeError &&
typeof maybeError === 'object' &&
'message' in maybeError &&
typeof maybeError.message === 'string'
)
}
// ✅ Good - Using type predicate with schema validation
function isApp(app: unknown): app is App {
return AppSchema.safeParse(app).success
}
// ✅ Good - Using type predicate with composition
function isExampleApp(app: unknown): app is ExampleApp {
return isApp(app) && app.type === 'example'
}
// ❌ Avoid - Not using type predicate
function isApp(app: unknown): boolean {
return typeof app === 'object' && app !== null
}
Type predicates use the syntax parameterName is Type
to tell TypeScript that
the function checks if the parameter is of the specified type. This allows
TypeScript to narrow the type in code blocks where the function returns true.
// Usage example:
const maybeApp: unknown = getSomeApp()
if (isExampleApp(maybeApp)) {
// TypeScript now knows that maybeApp is definitely an ExampleApp
maybeApp.type // TypeScript knows this is 'example'
}
Use schema validation (like Zod) for runtime type checking and type inference when working with something that crosses the boundary of your codebase.
// ✅ Good
const OAuthData = z.object({
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: z.date(),
})
type OAuthData = z.infer<typeof OAuthDataSchema>
const oauthData = OAuthDataSchema.parse(rawData)
// ❌ Avoid
type OAuthData = {
accessToken: string
refreshToken: string
expiresAt: Date
}
const oauthData = rawData as OAuthData
Use unknown
instead of any
for values of unknown type. This forces you to
perform type checking before using the value.
// ✅ Good
function handleError(error: unknown) {
if (isError(error)) {
console.error(error.message)
} else {
console.error('An unknown error occurred')
}
}
// ❌ Avoid
function handleError(error: any) {
console.error(error.message)
}
Avoid implicit type coercion. Use explicit type conversion when needed. An exception to this is working with truthiness.
// ✅ Good
const number = Number(stringValue)
const string = String(numberValue)
if (user) {
// ...
}
// ❌ Avoid
const number = +stringValue
const string = '' + numberValue
if (Boolean(user)) {
// ...
}
Learn and follow Artem's Naming Cheatsheet. Here's a summary:
// ✅ Good
const firstName = 'Kent'
const friends = ['Kate', 'John']
const pageCount = 5
const hasPagination = postCount > 10
const shouldPaginate = postCount > 10
// ❌ Avoid
const primerNombre = 'Kent'
const amis = ['Kate', 'John']
const page_count = 5
const isPaginatable = postCount > 10
const onItmClk = () => {}
Key principles:
- Use English for all names
- Be consistent with naming convention (camelCase, PascalCase, etc.)
- Names should be Short, Intuitive, and Descriptive (S-I-D)
- Avoid contractions and context duplication
- Function names should follow the A/HC/LC pattern:
- Action (get, set, handle, etc.)
- High Context (what it operates on)
- Low Context (optional additional context)
For example: getUserMessages
, handleClickOutside
, shouldDisplayMessage
Define event constants using a const object. Use uppercase with underscores for event names.
// ✅ Good
export const EVENTS = {
USER_CODE_RECEIVED: 'USER_CODE_RECEIVED',
AUTH_RESOLVED: 'AUTH_RESOLVED',
AUTH_REJECTED: 'AUTH_REJECTED',
} as const
// ❌ Avoid
export const events = {
userCodeReceived: 'userCodeReceived',
authResolved: 'authResolved',
authRejected: 'authRejected',
}
Use TypeScript to define event types based on the event constants.
// ✅ Good
export type EventTypes = keyof typeof EVENTS
// ❌ Avoid
export type EventTypes =
| 'USER_CODE_RECEIVED'
| 'AUTH_RESOLVED'
| 'AUTH_REJECTED'
Define Zod schemas for event payloads to ensure type safety and runtime validation.
// ✅ Good
const CodeReceivedEventSchema = z.object({
type: z.literal(EVENTS.USER_CODE_RECEIVED),
code: z.string(),
url: z.string(),
})
// ❌ Avoid
type CodeReceivedEvent = {
type: 'USER_CODE_RECEIVED'
code: string
url: string
}
Note: This is primarily useful because in event systems, you're typically crossing a boundary of your codebase (network etc.).
Always clean up event listeners when they're no longer needed.
// ✅ Good
authEmitter.on(EVENTS.USER_CODE_RECEIVED, handleCodeReceived)
return () => {
authEmitter.off(EVENTS.USER_CODE_RECEIVED, handleCodeReceived)
}
// ❌ Avoid
authEmitter.on(EVENTS.USER_CODE_RECEIVED, handleCodeReceived)
// No cleanup
Make certain to cover error cases and emit events for those.
// ✅ Good
try {
// event handling logic
} catch (error) {
authEmitter.emit(EVENTS.AUTH_REJECTED, {
error: getErrorMessage(error),
})
}
// ❌ Avoid
try {
// event handling logic
} catch (error) {
console.error(error)
}
Instead of using useEffect
, use ref callbacks, event handlers with
flushSync
, css, useSyncExternalStore
, etc.
// This example was ripped from the docs:
// ✅ Good
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product)
showNotification(`Added ${product.name} to the shopping cart!`)
}
function handleBuyClick() {
buyProduct()
}
function handleCheckoutClick() {
buyProduct()
navigateTo('/checkout')
}
// ...
}
useEffect(() => {
setCount(count + 1)
}, [count])
// ❌ Avoid
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`)
}
}, [product])
function handleBuyClick() {
addToCart(product)
}
function handleCheckoutClick() {
addToCart(product)
navigateTo('/checkout')
}
// ...
}
There are a lot more examples in the docs. useEffect
is not banned or
anything. There are just better ways to handle most cases.
Here's an example of a situation where useEffect
is appropriate:
// ✅ Good
useEffect(() => {
const controller = new AbortController()
window.addEventListener(
'keydown',
(event: KeyboardEvent) => {
if (event.key !== 'Escape') return
// do something based on escape key being pressed
},
{ signal: controller.signal },
)
return () => {
controller.abort()
}
}, [])
// ✅ Good
const [count, setCount] = useState(0)
const isEven = count % 2 === 0
// ❌ Avoid
const [count, setCount] = useState(0)
const [isEven, setIsEven] = useState(false)
useEffect(() => {
setIsEven(count % 2 === 0)
}, [count])
In JSX, do not render falsy values other than null
.
// ✅ Good
<div>
{contacts.length ? <div>You have {contacts.length} contacts</div> : null}
</div>
// ❌ Avoid
<div>
{contacts.length && <div>You have {contacts.length} contacts</div>}
</div>
Use ternaries for simple conditionals. When automatically formatted, they should be plenty readable, even on multiple lines. Ternaries are also the only conditional in the spec (currently) which are expressions and can be used in return statements and other places expressions are used.
// ✅ Good
const isAdmin = user.role === 'admin'
const access = isAdmin ? 'granted' : 'denied'
function App({ user }: { user: User }) {
return (
<div className="App">
{user.role === 'admin' ? <Link to="/admin">Admin</Link> : null}
</div>
)
}
Test components based on how users actually interact with them, not implementation details:
The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds
// ✅ Good
test('User can add items to cart', async () => {
render(<ProductList />)
await userEvent.click(screen.getByRole('button', { name: /add to cart/i }))
await expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument()
})
// ❌ Avoid
test('Cart state updates when addToCart is called', () => {
const { container } = render(<ProductList />)
const addButton = container.querySelector('[data-testid="add-button"]')
fireEvent.click(addButton)
expect(
container.querySelector('[data-testid="cart-count"]'),
).toHaveTextContent('1')
})
Only mock what's absolutely necessary. Most of the time, you don't need to mock any of your own code or even dependency code.
// ✅ Good
function Greeting({ name }: { name: string }) {
return <div>Hello {name}</div>
}
test('Greeting displays the name', () => {
render(<Greeting name="Kent" />)
expect(screen.getByText('Hello Kent')).toBeInTheDocument()
})
// ❌ Avoid
test('Greeting displays the name', () => {
const mockName = 'Kent'
vi.mock('./greeting.tsx', () => ({
Greeting: () => <div>Hello {mockName}</div>,
}))
render(<Greeting name={mockName} />)
expect(container).toHaveTextContent('Hello Kent')
})
Use MSW (Mock Service Worker) to mock external services. This allows you to test your application's integration with external APIs without actually making network requests.
// ✅ Good
import { setupServer } from 'msw/node'
import { http } from 'msw'
const server = setupServer(
http.get('/api/user', async ({ request }) => {
return HttpResponse.json({
name: 'Kent',
role: 'admin',
})
}),
)
test('User data is fetched and displayed', async () => {
render(<UserProfile />)
await expect(await screen.findByText('Kent')).toBeInTheDocument()
})
// ❌ Avoid
test('User data is fetched and displayed', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
json: () => Promise.resolve({ name: 'Kent', role: 'admin' }),
})
render(<UserProfile />)
await expect(await screen.findByText('Kent')).toBeInTheDocument()
})
Use the test
function instead of describe
and it
. This makes tests more
straightforward and easier to understand.
// ✅ Good
test('User can log in with valid credentials', async () => {
render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'kent@example.com',
)
await userEvent.type(
screen.getByRole('textbox', { name: /password/i }),
'password123',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await expect(await screen.findByText('Welcome back!')).toBeInTheDocument()
})
// ❌ Avoid
describe('LoginForm', () => {
it('should allow user to log in with valid credentials', async () => {
render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'kent@example.com',
)
await userEvent.type(
screen.getByRole('textbox', { name: /password/i }),
'password123',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await expect(await screen.findByText('Welcome back!')).toBeInTheDocument()
})
})
Keep your tests flat. Nesting makes tests harder to understand and maintain.
// ✅ Good
test('User can log in', async () => {
render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'kent@example.com',
)
await userEvent.type(
screen.getByRole('textbox', { name: /password/i }),
'password123',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await expect(await screen.findByText('Welcome back!')).toBeInTheDocument()
})
// ❌ Avoid
describe('LoginForm', () => {
describe('when user enters valid credentials', () => {
it('should show welcome message', async () => {
render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'kent@example.com',
)
await userEvent.type(
screen.getByRole('textbox', { name: /password/i }),
'password123',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await expect(await screen.findByText('Welcome back!')).toBeInTheDocument()
})
})
})
// ✅ Good
test('renders a greeting', () => {
render(<Greeting name="Kent" />)
expect(screen.getByText('Hello Kent')).toBeInTheDocument()
})
// ❌ Avoid
let utils
beforeEach(() => {
utils = render(<Greeting name="Kent" />)
})
test('renders a greeting', () => {
expect(utils.getByText('Hello Kent')).toBeInTheDocument()
})
Note: Most of the time your individual tests can avoid the use of
beforeEach
andafterEach
altogether and it's only global setup that needs it (like mocking outconsole.log
or setting up a mock server).
Test your components based on how they're used, not how they're implemented.
// ✅ Good
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
}
test('Counter increments when clicked', async () => {
render(<Counter />)
const button = screen.getByRole('button')
await userEvent.click(button)
expect(getByText('Count: 1')).toBeInTheDocument()
})
// ❌ Avoid
test('Counter increments when clicked', () => {
const { container } = render(<Counter />)
const button = container.querySelector('button')
fireEvent.click(button)
const state = container.querySelector('[data-testid="count"]')
expect(state).toHaveTextContent('1')
})
Make your assertions as specific as possible to catch the exact behavior you're testing.
// ✅ Good
test('Form shows error for invalid email', async () => {
render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'invalid-email',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await expect(
await screen.findByText(/enter a valid email/i),
).toBeInTheDocument()
})
// ❌ Avoid
test('Form shows error for invalid email', async () => {
const { container } = render(<LoginForm />)
await userEvent.type(
screen.getByRole('textbox', { name: /email/i }),
'invalid-email',
)
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(container).toMatchSnapshot()
})
Prioritize your tests according to the Testing Trophy:
- Static Analysis (TypeScript, ESLint)
- Unit Tests (Pure Functions)
- Integration Tests (Component Integration)
- E2E Tests (Critical User Flows)
// ✅ Good
// 1. Static Analysis
function add(a: number, b: number): number {
return a + b
}
// 2. Unit Tests
test('add function adds two numbers', () => {
expect(add(1, 2)).toBe(3)
})
// 3. Integration Tests
test('Calculator component adds numbers', async () => {
render(<Calculator />)
await userEvent.click(screen.getByRole('button', { name: '1' }))
await userEvent.click(screen.getByRole('button', { name: '+' }))
await userEvent.click(screen.getByRole('button', { name: '2' }))
await userEvent.click(screen.getByRole('button', { name: '=' }))
expect(getByText('3')).toBeInTheDocument()
})
// 4. E2E Tests (using Playwright)
await page.goto('/calculator')
await expect(page.getByRole('button', { name: '0' })).toBeInTheDocument()
await page.getByRole('button', { name: '1' }).click()
await page.getByRole('button', { name: '+' }).click()
await page.getByRole('button', { name: '2' }).click()
await page.getByRole('button', { name: '=' }).click()
await expect(page.getByRole('button', { name: '3' })).toBeInTheDocument()
// ❌ Avoid
// Don't write E2E tests for everything
test('every button click updates display', () => {
render(<Calculator />)
// Testing every possible button combination...
})
Follow the query priority order and avoid using container queries:
// ✅ Good
screen.getByRole('textbox', { name: /username/i })
// ❌ Avoid
screen.getByTestId('username')
container.querySelector('.btn-primary')
Only use query* variants for checking non-existence:
// ✅ Good
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// ❌ Avoid
expect(screen.queryByRole('alert')).toBeInTheDocument()
Use find* queries instead of waitFor for elements that may not be immediately available:
// ✅ Good
const submitButton = await screen.findByRole('button', { name: /submit/i })
// ❌ Avoid
const submitButton = await waitFor(() =>
screen.getByRole('button', { name: /submit/i }),
)
Test components based on how users interact with them, not implementation details:
// ✅ Good
test('User can add items to cart', async () => {
render(<ProductList />)
await userEvent.click(screen.getByRole('button', { name: /add to cart/i }))
await expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument()
})
// ❌ Avoid
test('Cart state updates when addToCart is called', () => {
const { container } = render(<ProductList />)
const addButton = container.querySelector('[data-testid="add-button"]')
fireEvent.click(addButton)
expect(
container.querySelector('[data-testid="cart-count"]'),
).toHaveTextContent('1')
})
Use userEvent over fireEvent for more realistic user interactions:
// ✅ Good
await userEvent.type(screen.getByRole('textbox'), 'Hello')
// ❌ Avoid
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello' } })
Use kebab-case for file names.
// ✅ Good
import { HighlightButton } from './highlight-button'
// ❌ Avoid
import { HighlightButton } from './HighlightButton'
It makes things work consistently on Windows and Unix-based systems.