Reference
Lint rules
Every rule uf lint knows, with an example it reports and an example it
accepts. Each example is run by uf's test suite, and the findings shown
under a Bad example are the findings uf lint prints for it.
The level is the default. uf lint --rules prints the level each rule runs
at in your project, and whether a fix applies. Rules marked needs type
inference are listed but do not run yet: uf reports them as unavailable,
and their examples show what they will check.
Flow
Flow's own lints and the parse check, configured beside uf's rules.
| Rule | Default | What it checks |
|---|---|---|
flow/ambiguous-object-type | off | object type annotations must state exactness explicitly |
flow/default-import-access | error · needs type inference | do not read named exports off a default import |
flow/deprecated-type | error | the bool type alias is deprecated; write boolean |
flow/export-renamed-default | warn | avoid export { value as default }; use an explicit default export |
flow/internal-type | error | do not reference Flow's internal types directly |
flow/invalid-import-star-use | error · needs type inference | namespace imports may only be used for member access |
flow/invalid-this-arg | error · needs type inference | do not rebind a method to an incompatible receiver |
flow/libdef-override | error · needs type inference | library definitions must not override built-in declarations |
flow/mixed-import-and-require | error | do not mix import and require in one module |
flow/nested-component | error | do not declare a component inside another component or hook |
flow/nested-hook | error | do not declare a hook inside another component or hook |
flow/non-const-var-export | error | exported bindings must be const |
flow/nonstrict-import | warn · needs type inference | @flow strict modules may only import other strict modules |
flow/react-intrinsic-overlap | error · needs type inference | a value used as a component must not overlap a JSX intrinsic |
flow/require-explicit-enum-checks | warn · needs type inference | compare Flow enum values explicitly instead of testing truthiness |
flow/require-explicit-enum-switch-cases | warn · needs type inference | list Flow enum switch cases explicitly instead of relying on default |
flow/sketchy-null | error · needs type inference | existence check on a value that may be both nullish and falsey |
flow/sketchy-null-bigint | off · needs type inference | existence check on a ?bigint (covered by flow/sketchy-null) |
flow/sketchy-null-bool | off · needs type inference | existence check on a ?boolean (covered by flow/sketchy-null) |
flow/sketchy-null-mixed | off · needs type inference | existence check on a mixed value (covered by flow/sketchy-null) |
flow/sketchy-null-number | off · needs type inference | existence check on a ?number (covered by flow/sketchy-null) |
flow/sketchy-null-string | off · needs type inference | existence check on a ?string (covered by flow/sketchy-null) |
flow/sketchy-number | error · needs type inference | a number in a boolean position renders or branches on 0 |
flow/syntax | error | the file must parse with the official Flow parser |
flow/this-in-exported-function | warn · needs type inference | avoid this inside an exported standalone function |
flow/unclear-type | error | avoid any, Object, and Function type annotations |
flow/uninitialized-instance-property | error · needs type inference | do not read an instance property before the constructor initializes it |
flow/unnecessary-invariant | warn · needs type inference | invariant on a condition already known to be truthy |
flow/unnecessary-optional-chain | warn | ?. applied to a base that can never be nullish |
flow/unsafe-getters-setters | warn | avoid getters and setters; they hide side effects behind property access |
flow/unsafe-object-assign | error | prefer object spread over Object.assign |
flow/untyped-import | error · needs type inference | importing from an untyped module produces any |
flow/untyped-type-import | error · needs type inference | importing a type from an untyped module produces an any alias |
flow/unused-promise | error · needs type inference | do not ignore a Promise; await it or handle its rejection |
React
Component and hook syntax, JSX shape, and memoization the compiler already does.
| Rule | Default | What it checks |
|---|---|---|
react/button-has-type | warn | a button defaults to submit, so say which kind it is |
react/checked-requires-onchange-or-readonly | error | a checked input needs an onChange or a readOnly |
react/component-syntax | warn | declare React components with Flow component syntax |
react/hook-syntax | warn | declare React hooks with Flow hook syntax |
react/jsx-key | error | an element built in an array or by map needs a key |
react/jsx-no-comment-textnodes | error | // and /* between JSX tags are text on the page, not comments |
react/jsx-no-duplicate-props | error | give each prop once; a repeated one silently replaces the first |
react/no-array-index-key | warn | key a list item by what it is, not by where it stands |
react/no-children-prop | warn | write children between the tags, not as a prop |
react/no-default-export-component | warn | modules that declare components must use named exports |
react/no-namespace | error | React has no way to render a namespaced element name |
react/no-redundant-memo | warn | drop a useMemo/useCallback the React Compiler already did |
react/no-this-in-sfc | error | this names nothing inside a component or hook |
react/no-unescaped-entities | warn | a > or } left in JSX text is usually a mistyped tag |
react/no-unknown-property | error | write the prop React spells, not the HTML attribute name |
react/no-unused-prop-types | off | a prop a component declares should be read by its body |
react/style-prop-object | error | style takes an object; React throws on anything else |
react/void-dom-elements-no-children | error | void elements such as img and br take no children |
React Compiler
The official React Compiler's diagnostics, one rule per category.
| Rule | Default | What it checks |
|---|---|---|
react-compiler/capitalized-calls | off | the React Compiler's CapitalizedCalls diagnostics: a capitalized function called instead of rendered with JSX |
react-compiler/error-boundaries | error | the React Compiler's ErrorBoundaries diagnostics: JSX built inside try/catch instead of under an error boundary |
react-compiler/exhaustive-effect-dependencies | off | the React Compiler's EffectExhaustiveDependencies diagnostics: an effect's dependency array missing or adding a value |
react-compiler/fbt | off | the React Compiler's FBT diagnostics, about Meta's fbt internationalization library |
react-compiler/globals | error | the React Compiler's Globals diagnostics: a variable from outside a component or hook changed during render |
react-compiler/hooks | error | the React Compiler's Hooks diagnostics: the Rules of Hooks |
react-compiler/immutability | error | the React Compiler's Immutability diagnostics: props, state or another value React treats as immutable, mutated |
react-compiler/incompatible-library | warn | the React Compiler's IncompatibleLibrary diagnostics: an API known to break memoization |
react-compiler/invariant | off | the React Compiler's Invariant diagnostics: an internal assumption of the compiler that did not hold |
react-compiler/memo-dependencies | warn | the React Compiler's MemoDependencies diagnostics: a useMemo or useCallback dependency array missing or adding a value |
react-compiler/no-deriving-state-in-effects | off | the React Compiler's EffectDerivationsOfState diagnostics: a value derived from props or state, computed in an effect |
react-compiler/preserve-manual-memoization | error | the React Compiler's PreserveManualMemo diagnostics: a useMemo or useCallback the compiler cannot preserve |
react-compiler/purity | error | the React Compiler's Purity diagnostics: a known-impure function called during render |
react-compiler/refs | error | the React Compiler's Refs diagnostics: a ref read or written during render |
react-compiler/set-state-in-effect | error | the React Compiler's EffectSetState diagnostics: setState called synchronously in an effect |
react-compiler/set-state-in-render | error | the React Compiler's RenderSetState diagnostics: setState called during render |
react-compiler/static-components | error | the React Compiler's StaticComponents diagnostics: a component created during render |
react-compiler/syntax | off | the React Compiler's Syntax diagnostics: code the compiler rejects as invalid syntax |
react-compiler/todo | off | the React Compiler's Todo diagnostics: code the compiler does not compile yet |
react-compiler/unsupported-syntax | warn | the React Compiler's UnsupportedSyntax diagnostics: syntax the compiler does not support |
react-compiler/use-memo | error | the React Compiler's UseMemo diagnostics: a useMemo callback that is async, a generator, or takes parameters |
react-compiler/void-use-memo | error | the React Compiler's VoidUseMemo diagnostics: a useMemo callback that returns nothing |
Accessibility
JSX that a screen reader, a keyboard or a switch cannot use.
| Rule | Default | What it checks |
|---|---|---|
a11y/alt-text | error | img, area and input type="image" need an alt |
a11y/anchor-ambiguous-text | warn | link text says where the link goes, not "click here" |
a11y/anchor-has-content | error | an a needs text or a label a screen reader can announce |
a11y/anchor-is-valid | error | an a needs a real href; an action belongs on a button |
a11y/aria-activedescendant-has-tabindex | error | aria-activedescendant needs an element that can take focus |
a11y/aria-props | error | an aria-* attribute must be one ARIA defines |
a11y/aria-proptypes | error | an aria-* value must be one its attribute takes |
a11y/aria-role | error | a role must be a real, non-abstract ARIA role |
a11y/aria-unsupported-elements | error | the elements ARIA reserves take no role and no aria-* |
a11y/autocomplete-valid | error | an autofill token the browser knows is what fills the field |
a11y/click-events-have-key-events | error | an element with a role and an onClick needs a key handler too |
a11y/control-has-associated-label | warn | a control needs a name a screen reader can announce |
a11y/heading-has-content | error | a heading needs text or a label a screen reader can announce |
a11y/heading-order | warn | heading levels go down one at a time |
a11y/html-has-lang | error | html needs a lang |
a11y/iframe-has-title | error | an iframe needs a title that says what it holds |
a11y/img-redundant-alt | warn | alt text does not call the image an image, photo or picture |
a11y/interactive-supports-focus | error | a widget role has to be reachable by keyboard |
a11y/label-has-associated-control | error | a label must name a control, by htmlFor or by holding it |
a11y/lang | error | the lang on html has to be a language tag |
a11y/media-has-caption | warn | audio and video need a captions track |
a11y/mouse-events-have-key-events | error | onMouseOver and onMouseOut need onFocus and onBlur beside them |
a11y/no-access-key | error | accessKey asks for a shortcut the browser has already given out |
a11y/no-aria-hidden-on-focusable | error | aria-hidden does not belong on an element that takes focus |
a11y/no-autofocus | error | autoFocus moves focus before the reader knows where they are |
a11y/no-distracting-elements | error | marquee and blink move with no way for the reader to stop them |
a11y/no-interactive-element-to-noninteractive-role | error | a control keeps its behaviour, so it must keep its announcement |
a11y/no-noninteractive-element-interactions | error | handlers belong on something announced as a control |
a11y/no-noninteractive-element-to-interactive-role | error | an element with its own semantics is not a control to relabel |
a11y/no-noninteractive-tabindex | warn | a tabIndex belongs on a control, not on what is only read |
a11y/no-redundant-roles | error | a role the element already has says nothing twice |
a11y/no-static-element-interactions | error | an onClick needs an element a keyboard can reach |
a11y/prefer-tag-over-role | warn | an element that is the role beats a role that says it |
a11y/role-has-required-aria-props | error | a role must be given the aria-* state it is announced by |
a11y/role-supports-aria-props | error | an element carries only the aria-* its role takes |
a11y/scope | error | scope belongs on a th and is ignored anywhere else |
a11y/tabindex-no-positive | error | a tabIndex above zero reorders the whole page |
Markup
HTML the browser's parser would rewrite or ignore.
| Rule | Default | What it checks |
|---|---|---|
markup/no-invalid-nesting | error | nest elements the HTML parser will leave where you wrote them |
markup/no-invalid-rel | warn | a rel keyword has to be one this element takes |
Imports
The static import graph: cycles, undeclared packages and paths.
| Rule | Default | What it checks |
|---|---|---|
import/no-absolute-path | error | imports must name packages or project-relative paths, not filesystem roots |
import/no-cycle | error | relative import graphs must not cycle back to the importing module |
import/no-deprecated | error | relative value imports must not use exports marked @deprecated |
import/no-duplicates | error | mergeable static imports from the same module belong together |
import/no-extraneous-dependencies | error | bare package imports must be declared by the nearest package.json |
import/no-named-as-default | error | a default import must not be named like one of the module's named exports |
import/no-relative-packages | error | workspace packages should import each other by package name |
import/no-self-import | error | a module must not import itself |
import/no-unused-modules | off | relative modules and value exports should be imported somewhere |
import/no-useless-path-segments | error | relative imports should name the shortest equivalent path |
Server
Server components, server actions and the client boundary.
| Rule | Default | What it checks |
|---|---|---|
server/no-client-secret | error | client modules must not read server secrets |
server/no-server-only-import-in-client | error | client modules must not import server-only modules |
server/use-client-directive-position | error | use client/use server must be the module's first statement |
server/use-server-actions | error | server action modules must open with "use server"; |
Router
File-system routes the router would not serve.
| Rule | Default | What it checks |
|---|---|---|
router/reserved-files | error | $* file names are reserved for layout, page, and middleware |
router/unsupported-segment | error | (.)segment directories must be interceptions uf reads, inside a @slot |
Security
Known vulnerability classes kept out of the code.
| Rule | Default | What it checks |
|---|---|---|
security/iframe-has-sandbox | error | an iframe needs a sandbox to bound what the page inside it may do |
security/no-dangerously-set-inner-html | error | render HTML only through a sanitizing @uniflowed/markdown helper |
security/no-eval | error | never turn strings into code via eval, new Function, or timer strings |
security/no-script-url | error | a javascript: URL is a program in a prop the browser navigates to |
security/no-target-blank | error | target="_blank" carries the page's URL on unless rel stops it |
Fetch
The instrumented fetch the toolchain is built around.
| Rule | Default | What it checks |
|---|---|---|
fetch/no-global-override | error | do not monkey-patch global fetch |
React Native
Platform-specific modules.
| Rule | Default | What it checks |
|---|---|---|
react-native/platform-split | warn | prefer platform-specific files over Platform.OS branches |
Vite
@uniflowed/vite configuration.
| Rule | Default | What it checks |
|---|---|---|
vite/hot-needs-optional-chaining | error | reach import.meta.hot through ?.; if does not refine it |
package.json
Manifests that shell out instead of declaring tasks.
| Rule | Default | What it checks |
|---|---|---|
package/no-npm-scripts | error | refuse install-time lifecycle scripts unless explicitly allowed |
House rules
Whitespace, tasks and the suppression comments themselves.
| Rule | Default | What it checks |
|---|---|---|
uniflowed/no-npm-script-invocation | error | shell out to uf tasks, not npm run/yarn/pnpm/bunx |
uniflowed/no-tabs | error | indent with spaces, never tabs |
uniflowed/no-trailing-whitespace | error | lines must not end in whitespace |
uniflowed/unknown-lint-suppression | error | uf-lint-disable comments must name a rule this linter knows |
uniflowed/unused-lint-suppression | warn | a uf-lint-disable comment must silence a finding |
Every rule, with examples
flow/ambiguous-object-type
Default: off. Object type annotations must state exactness explicitly.
An object type written as { id: string } means exact or inexact depending on the project's exact_by_default setting, so the same annotation reads differently in two projects. Say which one you mean: {| id: string |} for exact, or { id: string, ... } for inexact. Off by default, because Modern Flow makes objects exact by default.
Bad
// @flow
export type Props = { id: string };
uf lint reports:
app/example.js:2:21 object type is neither exact (`{| |}`) nor explicitly inexact (`...`)
Good
// @flow
export type Exact = {| id: string |};
export type Open = { id: string, ... };
flow/default-import-access
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Do not read named exports off a default import.
Reading a named export off a default import, as in React.useState where React is the default export, only works when the default export happens to be an object that also carries that property. Import the named export itself.
Bad
// @flow
import React from "react";
export hook useCount(): number {
const [count] = React.useState(0);
return count;
}
Good
// @flow
import { useState } from "react";
export hook useCount(): number {
const [count] = useState(0);
return count;
}
flow/deprecated-type
Default: error. The bool type alias is deprecated; write boolean.
bool is a deprecated alias Flow still accepts. Write boolean, the name Flow and every other type system use.
Bad
// @flow
export type Toggle = { readonly on: bool };
uf lint reports:
app/example.js:2:37 the `bool` type alias is deprecated; write `boolean`
Good
// @flow
export type Toggle = { readonly on: boolean };
flow/export-renamed-default
Default: warn. Avoid export { value as default }; use an explicit default export.
export { value as default } makes a default export that is easy to miss when reading the module's exports. Write export default where the value is declared.
Bad
// @flow
const page = { title: "Home" };
export { page as default };
uf lint reports:
app/example.js:3:15 renaming an export to `default` hides the real name; export it directly
Good
// @flow
const page = { title: "Home" };
export default page;
flow/internal-type
Default: error. Do not reference Flow's internal types directly.
Names like React$Node and $TEMPORARY$object are internal to Flow's library definitions and can change between Flow releases without notice. Import the public type instead.
Bad
// @flow
export type Slot = React$Node;
uf lint reports:
app/example.js:2:20 this is a Flow-internal type; use the public equivalent
Good
// @flow
import type { Node } from "react";
export type Slot = Node;
flow/invalid-import-star-use
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Namespace imports may only be used for member access.
A namespace import (import * as ns) is a module object that Flow can only check when you read members off it. Passing the namespace around as a value loses those checks.
Bad
// @flow
import * as math from "./math.js";
export const helpers = math;
Good
// @flow
import * as math from "./math.js";
export const total: number = math.sum([1, 2, 3]);
flow/invalid-this-arg
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Do not rebind a method to an incompatible receiver.
Calling a method through .call, .apply or .bind with a receiver of the wrong type runs the method with a this it was never written for.
Bad
// @flow
class Counter {
count: number = 0;
increment(): void {
this.count += 1;
}
}
const counter = new Counter();
counter.increment.call({ total: 0 });
Good
// @flow
class Counter {
count: number = 0;
increment(): void {
this.count += 1;
}
}
const counter = new Counter();
counter.increment.call(counter);
flow/libdef-override
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Library definitions must not override built-in declarations.
A library definition that redeclares something Flow's built-in libraries already define, such as Array or Promise, silently replaces the real type for the whole project.
Bad
// @flow
// flow-typed/overrides.js
declare class Promise<+R> {
then(onFulfill: (value: R) => mixed): Promise<mixed>;
}
Good
// @flow
// flow-typed/my-sdk.js
declare module "my-sdk" {
declare export function connect(url: string): Promise<void>;
}
flow/mixed-import-and-require
Default: error. Do not mix import and require in one module.
A module that uses both import and require runs through two module systems, with different loading order and interop rules. Keep an ES module on import. When a CommonJS-only file has to be loaded, use the createRequire bridge that Node documents.
Bad
// @flow
import { readFile } from "node:fs/promises";
const path = require("node:path");
export async function read(name: string): Promise<string> {
return readFile(path.join("data", name), "utf8");
}
uf lint reports:
app/example.js:3:14 this module already uses `import`; do not mix in `require`
Good
// @flow
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function read(name: string): Promise<string> {
return readFile(path.join("data", name), "utf8");
}
flow/nested-component
Default: error. Do not declare a component inside another component or hook.
A component declared inside another component or hook is a new component on every render, so React unmounts and remounts it each time and its state is lost. Declare components at the top level of a module.
Bad
// @flow
export component List(items: $ReadOnlyArray<string>) {
component Row(label: string) {
return <li>{label}</li>;
}
return <ul>{items.map((item) => <Row key={item} label={item} />)}</ul>;
}
uf lint reports:
app/example.js:3:3 declare this component at module scope; nesting it remounts its subtree every render
Good
// @flow
component Row(label: string) {
return <li>{label}</li>;
}
export component List(items: $ReadOnlyArray<string>) {
return <ul>{items.map((item) => <Row key={item} label={item} />)}</ul>;
}
flow/nested-hook
Default: error. Do not declare a hook inside another component or hook.
A hook declared inside a component or another hook is a new function on every render, and the Rules of Hooks cannot be checked across it. Declare hooks at the top level of a module.
Bad
// @flow
import { useState } from "react";
export component Counter() {
hook useCount(): number {
const [count] = useState(0);
return count;
}
return <output>{useCount()}</output>;
}
uf lint reports:
app/example.js:5:3 declare this hook at module scope; a nested hook gets a new identity every render
Good
// @flow
import { useState } from "react";
hook useCount(): number {
const [count] = useState(0);
return count;
}
export component Counter() {
return <output>{useCount()}</output>;
}
flow/non-const-var-export
Default: error. Exported bindings must be const.
An exported let or var can be reassigned by the module after other modules have imported it, and the importers see the change through a live binding. Export a const, and export a function when callers need to change the value.
Bad
// @flow
export let count = 0;
uf lint reports:
app/example.js:2:8 exported bindings must be `const`; a mutable export is a live binding
Good
// @flow
let count = 0;
export const initial = 0;
export function increment(): number {
count += 1;
return count;
}
flow/nonstrict-import
Default: warn · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. @flow strict modules may only import other strict modules.
A @flow strict module promises that everything it touches is checked strictly. Importing a module that is not strict breaks that promise at the import.
Bad
// @flow strict
import { format } from "./legacy-format.js"; // a `@flow` module, not strict
export const label: string = format(3);
Good
// @flow strict
import { format } from "./format.js"; // also `@flow strict`
export const label: string = format(3);
flow/react-intrinsic-overlap
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. A value used as a component must not overlap a JSX intrinsic.
A value used as a JSX component whose type overlaps an intrinsic element's name, such as a string "div", renders a DOM element where you meant a component. Give JSX a component, not a string that may be a tag.
Bad
// @flow
export component Box(as: string) {
const Tag = as;
return <Tag />;
}
Good
// @flow
export component Box(as: "section" | "article") {
return as === "section" ? <section /> : <article />;
}
flow/require-explicit-enum-checks
Default: warn · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Compare Flow enum values explicitly instead of testing truthiness.
Testing a Flow enum value for truthiness hides which members you meant, and a member added later passes silently. Compare against the members explicitly.
Bad
// @flow
enum Status {
Active,
Paused,
}
export function isOn(status: ?Status): boolean {
return status ? true : false;
}
Good
// @flow
enum Status {
Active,
Paused,
}
export function isOn(status: ?Status): boolean {
return status === Status.Active;
}
flow/require-explicit-enum-switch-cases
Default: warn · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. List Flow enum switch cases explicitly instead of relying on default.
A default branch in a switch over a Flow enum catches members added later without anyone deciding what they should do. List every member, so that adding one is a type error until it is handled. match gives the same exhaustiveness check.
Bad
// @flow
enum Status {
Active,
Paused,
Closed,
}
export function label(status: Status): string {
switch (status) {
case Status.Active:
return "active";
default:
return "inactive";
}
}
Good
// @flow
enum Status {
Active,
Paused,
Closed,
}
export function label(status: Status): string {
return match (status) {
Status.Active => "active",
Status.Paused => "paused",
Status.Closed => "closed",
};
}
flow/sketchy-null
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a value that may be both nullish and falsey.
An existence check such as if (value) on a ?number is false both when the value is missing and when it is 0, and the code usually meant only one of them. Compare with null explicitly. This rule covers every such type; the sketchy-null-* rules name one type each.
Bad
// @flow
export function describe(value: ?number): string {
if (value) {
return "set";
}
return "missing";
}
Good
// @flow
export function describe(value: ?number): string {
if (value != null) {
return "set";
}
return "missing";
}
flow/sketchy-null-bigint
Default: off · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a ?bigint (covered by flow/sketchy-null).
An existence check such as if (value) on a ?bigint is false both when the value is missing and when it is 0n, and the code usually meant only one of them. Compare with null explicitly. Off by default: flow/sketchy-null covers it.
Bad
// @flow
export function describe(value: ?bigint): string {
if (value) {
return "set";
}
return "missing";
}
Good
// @flow
export function describe(value: ?bigint): string {
if (value != null) {
return "set";
}
return "missing";
}
flow/sketchy-null-bool
Default: off · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a ?boolean (covered by flow/sketchy-null).
An existence check such as if (value) on a ?boolean is false both when the value is missing and when it is false, and the code usually meant only one of them. Compare with null explicitly. Off by default: flow/sketchy-null covers it.
Bad
// @flow
export function describe(value: ?boolean): string {
if (value) {
return "set";
}
return "missing";
}
Good
// @flow
export function describe(value: ?boolean): string {
if (value != null) {
return "set";
}
return "missing";
}
flow/sketchy-null-mixed
Default: off · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a mixed value (covered by flow/sketchy-null).
An existence check such as if (value) on a mixed value is false for null and undefined, and also for 0, "" and false, which the code usually did not mean to reject. Compare with null explicitly. Off by default: flow/sketchy-null covers it.
Bad
// @flow
export function present(value: mixed): boolean {
if (value) {
return true;
}
return false;
}
Good
// @flow
export function present(value: mixed): boolean {
return value != null;
}
flow/sketchy-null-number
Default: off · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a ?number (covered by flow/sketchy-null).
An existence check such as if (value) on a ?number is false both when the value is missing and when it is 0, and the code usually meant only one of them. Compare with null explicitly. Off by default: flow/sketchy-null covers it.
Bad
// @flow
export function describe(value: ?number): string {
if (value) {
return "set";
}
return "missing";
}
Good
// @flow
export function describe(value: ?number): string {
if (value != null) {
return "set";
}
return "missing";
}
flow/sketchy-null-string
Default: off · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Existence check on a ?string (covered by flow/sketchy-null).
An existence check such as if (value) on a ?string is false both when the value is missing and when it is "", and the code usually meant only one of them. Compare with null explicitly. Off by default: flow/sketchy-null covers it.
Bad
// @flow
export function describe(value: ?string): string {
if (value) {
return "set";
}
return "missing";
}
Good
// @flow
export function describe(value: ?string): string {
if (value != null) {
return "set";
}
return "missing";
}
flow/sketchy-number
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. A number in a boolean position renders or branches on 0.
A number on the left of && in JSX renders 0 when it is zero, instead of rendering nothing. Turn it into a boolean first.
Bad
// @flow
export component Unread(count: number) {
return <p>{count && <span>{count} new</span>}</p>;
}
Good
// @flow
export component Unread(count: number) {
return <p>{count > 0 && <span>{count} new</span>}</p>;
}
flow/syntax
Default: error. The file must parse with the official Flow parser.
The file must parse with the official Flow parser, the parser uf compiles with. When it does not, nothing else about the file can be checked, so this finding comes first.
Bad
// @flow
export const total = 1 +;
uf lint reports:
app/example.js:2:25 Unexpected token `;`
Good
// @flow
export type Row = { id: string };
flow/this-in-exported-function
Default: warn · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Avoid this inside an exported standalone function.
this in an exported standalone function depends on how each caller happens to call it, and it is undefined in a plain call from another module. Take the value as a parameter.
Bad
// @flow
export function fullName(): string {
return `${this.first} ${this.last}`;
}
Good
// @flow
export function fullName(person: { readonly first: string, readonly last: string }): string {
return `${person.first} ${person.last}`;
}
flow/unclear-type
Default: error. Avoid any, Object, and Function type annotations.
any, Object and Function switch Flow off for whatever they touch: a value typed any accepts every operation, and so does everything it flows into. Say what the value is instead: mixed when it could be anything, an object or function type when you know its shape.
Bad
// @flow
export type Props = { value: any };
uf lint reports:
app/example.js:2:30 avoid `any`; use `mixed`, opaque types, or generated router/action types
Good
// @flow
export type Props = { value: mixed };
flow/uninitialized-instance-property
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Do not read an instance property before the constructor initializes it.
Reading an instance property in the constructor before it has been assigned reads undefined, whatever its declared type says.
Bad
// @flow
export class Cache {
size: number;
entries: Map<string, string>;
constructor() {
this.size = this.entries.size;
this.entries = new Map();
}
}
Good
// @flow
export class Cache {
entries: Map<string, string>;
size: number;
constructor() {
this.entries = new Map();
this.size = this.entries.size;
}
}
flow/unnecessary-invariant
Default: warn · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. invariant on a condition already known to be truthy.
An invariant whose condition Flow already knows is true checks nothing, and suggests to a reader that the value could be missing when it cannot.
Bad
// @flow
import invariant from "invariant";
export function first(items: [string, string]): string {
const head = items[0];
invariant(head, "a pair has a first item");
return head;
}
Good
// @flow
import invariant from "invariant";
export function first(items: $ReadOnlyArray<string>): string {
const head = items[0];
invariant(head != null, "the list is not empty");
return head;
}
flow/unnecessary-optional-chain
Default: warn. ?. applied to a base that can never be nullish.
?. on a value that can never be null or undefined, such as this, suggests to a reader that it can be. Use a plain ..
Bad
// @flow
export class Timer {
elapsed: number = 0;
read(): number {
return this?.elapsed;
}
}
uf lint reports:
app/example.js:5:12 `this` is never nullish; drop the `?.`
Good
// @flow
export class Timer {
elapsed: number = 0;
read(): number {
return this.elapsed;
}
}
flow/unsafe-getters-setters
Default: warn. Avoid getters and setters; they hide side effects behind property access.
A getter or setter runs code when a property is read or written, where a reader expects a plain field. Flow also cannot track what the side effect changes. Use a method, so the call is visible.
Bad
// @flow
export class Temperature {
celsius: number = 0;
get fahrenheit(): number {
return this.celsius * 1.8 + 32;
}
}
uf lint reports:
app/example.js:4:3 avoid getters and setters; they hide side effects behind property access
Good
// @flow
export class Temperature {
celsius: number = 0;
fahrenheit(): number {
return this.celsius * 1.8 + 32;
}
}
flow/unsafe-object-assign
Default: error. Prefer object spread over Object.assign.
Object.assign mutates its first argument, and Flow types the merge less precisely than an object spread. Spread into a new object.
Bad
// @flow
export function withDefaults(options: { readonly size?: number }): { readonly size?: number } {
return Object.assign({}, { size: 10 }, options);
}
uf lint reports:
app/example.js:3:10 prefer object spread over `Object.assign`, which mutates its target
Good
// @flow
export function withDefaults(options: { readonly size?: number }): { readonly size?: number } {
return { size: 10, ...options };
}
flow/untyped-import
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Importing from an untyped module produces any.
Importing a value from a module Flow has no types for gives you any, which switches off checking for everything that value flows into. Add types for the module, or import from one that has them.
Bad
// @flow
import leftPad from "left-pad"; // no types, no library definition
export const padded: string = leftPad("7", 3, "0");
Good
// @flow
export const padded: string = "7".padStart(3, "0");
flow/untyped-type-import
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Importing a type from an untyped module produces an any alias.
Importing a type from a module Flow has no types for gives you an any alias, which checks nothing wherever you use it. Add types for the module, or declare the type yourself.
Bad
// @flow
import type { Options } from "legacy-widget"; // no types, no library definition
export const defaults: Options = { size: 10 };
Good
// @flow
export type Options = { readonly size: number };
export const defaults: Options = { size: 10 };
flow/unused-promise
Default: error · needs Flow type inference, which uf does not implement yet, so the rule does not run and the examples below are not checked. Do not ignore a Promise; await it or handle its rejection.
A Promise that nobody awaits or handles fails silently: its rejection becomes an unhandled rejection, and whatever comes next runs before it has settled. Await it, return it, or attach a handler.
Bad
// @flow
declare function save(value: string): Promise<void>;
export async function submit(value: string): Promise<void> {
save(value);
}
Good
// @flow
declare function save(value: string): Promise<void>;
export async function submit(value: string): Promise<void> {
await save(value);
}
react/button-has-type
Default: warn. A button defaults to submit, so say which kind it is.
A button with no type is a submit button. Inside a form, a button meant to open a menu or clear a field submits the form instead. Write type="button", type="submit" or type="reset" so the choice is on the page.
Bad
// @flow
export component Toolbar(onClear: () => void) {
return <button onClick={onClear}>Clear</button>;
}
uf lint reports:
app/example.js:3:10 this `<button>` has no `type`, and HTML defaults one to `type="submit"`: inside a form it submits and navigates away, so whatever its `onClick` did is thrown away by the navigation. Write `type="button"` for a button that runs a handler, or `type="submit"` to say the submission is meant
Good
// @flow
export component Toolbar(onClear: () => void) {
return (
<button type="button" onClick={onClear}>
Clear
</button>
);
}
react/checked-requires-onchange-or-readonly
Default: error. A checked input needs an onChange or a readOnly.
A checked input with no onChange is controlled by React and cannot be changed by anyone: every click is undone on the next render. Handle the change, mark it readOnly, or use defaultChecked for an uncontrolled box.
Bad
// @flow
export component Agree(accepted: boolean) {
return <input type="checkbox" checked={accepted} />;
}
uf lint reports:
app/example.js:3:33 `checked` makes this input controlled, so React puts the prop's value back after every click and the reader cannot move it; add an `onChange` that stores the new value, or `readOnly` to say that not moving is what was meant. `defaultChecked` is the uncontrolled form, which a click does move
Good
// @flow
export component Agree(accepted: boolean, onToggle: (next: boolean) => void) {
return (
<input
type="checkbox"
checked={accepted}
onChange={(event) => onToggle(event.currentTarget.checked)}
/>
);
}
Good
// @flow
export component Remembered() {
return <input type="checkbox" defaultChecked />;
}
react/component-syntax
Default: warn. Declare React components with Flow component syntax.
Flow's component syntax types props exactly, lets renders say what a component may render, and is what the React Compiler reads a component as. A function or arrow that returns JSX gets none of that.
Bad
// @flow
import * as React from "react";
export function Greeting({ name }: { name: string }): React.Node {
return <p>Hello, {name}</p>;
}
uf lint reports:
app/example.js:4:1 prefer Flow `component` syntax for React components
Good
// @flow
export component Greeting(name: string) {
return <p>Hello, {name}</p>;
}
Good
A component whose type says what it renders.
// @flow
component Item(label: string) {
return <li>{label}</li>;
}
export component List(labels: $ReadOnlyArray<string>) renders* Item {
return labels.map((label) => <Item key={label} label={label} />);
}
react/hook-syntax
Default: warn. Declare React hooks with Flow hook syntax.
Flow's hook syntax makes a hook a hook to the type checker: Flow then enforces the Rules of Hooks on its callers and knows its result follows React's rules. A plain function useX is just a function whose name looks like a hook.
Bad
// @flow
import { useState } from "react";
function useToggle(initial: boolean): [boolean, () => void] {
const [on, setOn] = useState(initial);
return [on, () => setOn(!on)];
}
export { useToggle };
uf lint reports:
app/example.js:4:1 prefer Flow `hook` syntax for React hooks
Good
// @flow
import { useState } from "react";
export hook useToggle(initial: boolean): [boolean, () => void] {
const [on, setOn] = useState(initial);
return [on, () => setOn(!on)];
}
react/jsx-key
Default: error. An element built in an array or by map needs a key.
React matches list items between renders by key. Without one it matches them by position, so state and focus stay where an item was rather than with the item when the list changes.
Bad
// @flow
type Todo = { id: string, title: string };
export component Todos(todos: $ReadOnlyArray<Todo>) {
return <ul>{todos.map((todo) => <li>{todo.title}</li>)}</ul>;
}
uf lint reports:
app/example.js:5:35 `<li>` is returned from `map` with no `key`, so React matches the items by position and hands one item's state to another when the list is reordered or filtered; give it `key={…}` with the item's id
Good
// @flow
type Todo = { id: string, title: string };
export component Todos(todos: $ReadOnlyArray<Todo>) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
react/jsx-no-comment-textnodes
Default: error. // and /* between JSX tags are text on the page, not comments.
Between JSX tags, // and /* are text, not comments, so they end up on the page. A comment in JSX goes inside braces: {/* like this */}.
Bad
// @flow
export component Empty() {
return <div>// nothing yet</div>;
}
uf lint reports:
app/example.js:3:15 `//` between JSX tags is text, not a comment, so it shows on the page; write `{/* … */}` to comment it out, or `{"// …"}` if the slashes are meant to be seen
Good
// @flow
export component Empty() {
return <div>{/* nothing yet */}</div>;
}
react/jsx-no-duplicate-props
Default: error. Give each prop once; a repeated one silently replaces the first.
When a prop is written twice, the last one wins and the first is silently thrown away. It is almost always a copy-and-paste mistake.
Bad
// @flow
export component Avatar(src: string) {
return <img src={src} alt="" alt="Profile picture" />;
}
uf lint reports:
app/example.js:3:32 `alt` is given twice on `<img>`, and only the last one reaches it; remove the one that was not meant
Good
// @flow
export component Avatar(src: string) {
return <img src={src} alt="Profile picture" />;
}
react/no-array-index-key
Default: warn. Key a list item by what it is, not by where it stands.
An index key ties each item's state to its position. When the list is reordered, filtered or added to at the front, React hands one item's state, focus and input text to another. Key by something the item carries, such as its id.
Bad
// @flow
type Row = { id: string, name: string };
export component Rows(rows: $ReadOnlyArray<Row>) {
return rows.map((row, index) => <input key={index} defaultValue={row.name} />);
}
uf lint reports:
app/example.js:5:42 `key` is built from the list index `index`, so React ties each item's state to its position and hands it to a different item when the list is reordered, filtered or added to at the front; key it by something the item carries, such as its id
Good
// @flow
type Row = { id: string, name: string };
export component Rows(rows: $ReadOnlyArray<Row>) {
return rows.map((row) => <input key={row.id} defaultValue={row.name} />);
}
react/no-children-prop
Default: warn. Write children between the tags, not as a prop.
Children belong between the tags. Passed as a children prop they are easy to miss when reading, and anything nested between the tags silently replaces them.
Bad
// @flow
export component Notice(text: string) {
return <p children={text} />;
}
uf lint reports:
app/example.js:3:13 pass `children` to `<p>` between its tags rather than as a prop, which is where a reader of JSX looks for them
Good
// @flow
export component Notice(text: string) {
return <p>{text}</p>;
}
react/no-default-export-component
Default: warn. Modules that declare components must use named exports.
A named export keeps a component's name the same at every import site, so it can be searched for, renamed by tools and read in stack traces. uf's router reads a page module's default export or its named Page, so a named export works for routes too.
Bad
// @flow
component Settings() {
return <h1>Settings</h1>;
}
export default Settings;
uf lint reports:
app/example.js:6:1 framework routes are wired by name; export components with a named export
Good
// @flow
export component Settings() {
return <h1>Settings</h1>;
}
react/no-namespace
Default: error. React has no way to render a namespaced element name.
React cannot render a namespaced element name such as svg:rect. JSX parses it, but React DOM has no element to create for it. Write the element's own name.
Bad
// @flow
export component Dot() {
return (
<svg viewBox="0 0 10 10">
<svg:circle cx="5" cy="5" r="5" />
</svg>
);
}
uf lint reports:
app/example.js:5:8 `<svg:circle>` is a namespaced element name, which React does not support: JSX reads a lowercase name as an HTML tag and a capitalised one as a value in scope, and `svg:circle` is neither, so there is nothing for React to render. Inside an `<svg>` the children are already in the SVG namespace — write `<circle>`
Good
// @flow
export component Dot() {
return (
<svg viewBox="0 0 10 10">
<circle cx="5" cy="5" r="5" />
</svg>
);
}
react/no-redundant-memo
Default: warn. Drop a useMemo/useCallback the React Compiler already did.
The React Compiler memoizes every component and hook it compiles. A hand-written useMemo or useCallback in one of them is a second copy of work the compiler already did, with a dependency array that someone now has to keep correct. The rule reports only memoization the compiler removed.
Bad
// @flow
import { useMemo } from "react";
export component Total(prices: $ReadOnlyArray<number>) {
const total = useMemo(() => prices.reduce((sum, price) => sum + price, 0), [prices]);
return <output>{total}</output>;
}
uf lint reports:
app/example.js:5:17 the React Compiler memoizes this already; `useMemo` here is a second dependency array to keep correct
Good
// @flow
export component Total(prices: $ReadOnlyArray<number>) {
const total = prices.reduce((sum, price) => sum + price, 0);
return <output>{total}</output>;
}
react/no-this-in-sfc
Default: error. this names nothing inside a component or hook.
A component or hook is a function called by React, not a method, so this inside one is undefined. It usually means code was moved over from a class component and still reads this.props.
Bad
// @flow
export component Title(text: string) {
return <h1>{this.props.text}</h1>;
}
uf lint reports:
app/example.js:3:15 `this` names nothing inside a `component`: Flow calls one as a plain function, so `this` is `undefined` here and reading anything off it throws; take the value from a parameter, or from the scope around the declaration
Good
// @flow
export component Title(text: string) {
return <h1>{text}</h1>;
}
react/no-unescaped-entities
Default: warn. A > or } left in JSX text is usually a mistyped tag.
A > or } in JSX text is legal, but it is usually a tag or an expression typed one character wrong. Write the character as an entity, or as a string in braces, when you mean it.
Bad
// @flow
export component Breadcrumb() {
return <p>Home > Settings</p>;
}
uf lint reports:
app/example.js:3:18 `>` in JSX text renders as itself, and is usually what a mistyped tag or a dropped brace left behind; write `>` if it is meant to be read
Good
// @flow
export component Breadcrumb() {
return <p>Home > Settings</p>;
}
react/no-unknown-property
Default: error. Write the prop React spells, not the HTML attribute name.
React spells some DOM attributes differently from HTML: className, htmlFor, tabIndex. React warns about the HTML spelling at run time and may drop it; this reports it while you write it.
Bad
// @flow
export component Field() {
return (
<label class="field" for="email">
Email
</label>
);
}
uf lint reports:
app/example.js:4:12 `class` is the HTML spelling of this attribute and React spells the prop `className`, so the name written on this `<label>` is not the one React binds and whatever it was meant to do does not happen — React reports it as an unknown DOM property in development. Write `className`
app/example.js:4:26 `for` is the HTML spelling of this attribute and React spells the prop `htmlFor`, so the name written on this `<label>` is not the one React binds and whatever it was meant to do does not happen — React reports it as an unknown DOM property in development. Write `htmlFor`
Good
// @flow
export component Field() {
return (
<label className="field" htmlFor="email">
Email
</label>
);
}
react/no-unused-prop-types
Default: off. A prop a component declares should be read by its body.
A prop that a component declares and never reads is part of its type that does nothing. Callers keep passing it for no effect. Remove it, or use it.
Bad
// @flow
export component Badge(label: string, tone: "info" | "warning") {
return <span>{label}</span>;
}
uf lint reports:
app/example.js:2:39 `<Badge>` declares the prop `tone` and never reads it, so every caller is asked for a value that goes nowhere; read it, or drop it from the parameter list
Good
// @flow
export component Badge(label: string, tone: "info" | "warning") {
return <span data-tone={tone}>{label}</span>;
}
react/style-prop-object
Default: error. style takes an object; React throws on anything else.
React's style takes an object of properties, not a CSS string. A string throws at render time.
Bad
// @flow
export component Spacer() {
return <div style="height: 1rem" />;
}
uf lint reports:
app/example.js:3:15 `style` on this `<div>` is a string, and React's `style` prop takes an object mapping property names to values — it throws on anything else rather than rendering it, so this is a crash rather than a matter of taste. Write `style={{ color: "red" }}`, with the property names camelCased
Good
// @flow
export component Spacer() {
return <div style={{ height: "1rem" }} />;
}
react/void-dom-elements-no-children
Default: error. Void elements such as img and br take no children.
img, br, input and the other void elements cannot have children in HTML. React throws when it is given some.
Bad
// @flow
export component Photo(src: string) {
return <img src={src}>A cat asleep</img>;
}
uf lint reports:
app/example.js:3:10 `<img>` is a void element and cannot hold children: React throws when it renders one that does; put the content next to it instead
Good
// @flow
export component Photo(src: string) {
return <img src={src} alt="A cat asleep" />;
}
react-compiler/capitalized-calls
Default: off. The React Compiler's CapitalizedCalls diagnostics: a capitalized function called instead of rendered with JSX.
A function whose name starts with a capital is read as a component. Called like a function, it runs inside the caller's render with the caller's hooks, and the compiler cannot tell a component call from a plain function call. Render components with JSX; give helpers lower-case names.
Bad
// @flow
import { FormatPrice } from "./format.js";
export component Price(amount: number) {
const text = FormatPrice(amount);
return <span>{text}</span>;
}
uf lint reports:
app/example.js:5:16 Capitalized functions are reserved for components, which must be invoked with JSX. If this is a component, render it with JSX. Otherwise, ensure that it has no hook calls and rename it to begin with a lowercase letter. Alternatively, if you know for a fact that this function is not a component, you can allowlist it via the compiler config. FormatPrice may be a component.
Good
// @flow
import { formatPrice } from "./format.js";
export component Price(amount: number) {
const text = formatPrice(amount);
return <span>{text}</span>;
}
react-compiler/error-boundaries
Default: error. The React Compiler's ErrorBoundaries diagnostics: JSX built inside try/catch instead of under an error boundary.
try/catch around JSX does not catch errors thrown while rendering it: the element is only a description, and React renders it later, outside the try. Put an error boundary around the subtree instead.
Bad
// @flow
import { Chart } from "./chart.js";
export component Panel(data: $ReadOnlyArray<number>) {
let content;
try {
content = <Chart data={data} />;
} catch {
content = <p>Could not draw the chart.</p>;
}
return content;
}
uf lint reports:
app/example.js:7:15 Avoid constructing JSX within try/catch. React does not immediately render components when JSX is rendered, so any errors from this component will not be caught by the try/catch. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary).
Good
// @flow
import { ErrorBoundary } from "@uniflowed/react";
import { Chart } from "./chart.js";
export component Panel(data: $ReadOnlyArray<number>) {
return (
<ErrorBoundary fallback={<p>Could not draw the chart.</p>}>
<Chart data={data} />
</ErrorBoundary>
);
}
react-compiler/exhaustive-effect-dependencies
Default: off. The React Compiler's EffectExhaustiveDependencies diagnostics: an effect's dependency array missing or adding a value.
An effect's dependency array says when it runs again. A value read inside the effect and missing from the array leaves the effect using a stale value; a value listed but never read re-runs it for nothing. Off by default, as in eslint-plugin-react-hooks.
Bad
// @flow
import { useEffect } from "react";
export component Title(count: number) {
useEffect(() => {
document.title = `${count} unread`;
}, []);
return null;
}
uf lint reports:
app/example.js:6:25 Found missing effect dependencies. Missing dependencies can cause an effect to fire less often than it should.
Good
// @flow
import { useEffect } from "react";
export component Title(count: number) {
useEffect(() => {
document.title = `${count} unread`;
}, [count]);
return null;
}
react-compiler/fbt
uf does not report this yet. It runs, but react_compiler 0.1.0 never files a diagnostic under FBT. A malformed fbt tag is reported as Invariant or Todo instead, so this rule has nothing to report yet. The Bad example below is what the category is about.
Default: off. The React Compiler's FBT diagnostics, about Meta's fbt internationalization library.
Meta's fbt internationalization library has JSX with rules of its own, and the compiler checks the parts it has to understand to compile it. Off by default: most projects do not use fbt.
Bad
An fbt tag must be the module-level import, not a local value that happens to be called fbt.
// @flow
export component Greeting(fbt: (text: string) => string) {
return <fbt desc="Greeting">Hello</fbt>;
}
Good
// @flow
import fbt from "fbt";
export component Greeting(name: string) {
return (
<fbt desc="Greeting">
Hello, <fbt:param name="name">{name}</fbt:param>
</fbt>
);
}
react-compiler/globals
Default: error. The React Compiler's Globals diagnostics: a variable from outside a component or hook changed during render.
Rendering must not change anything outside the component. A module variable reassigned during render changes on every render, including renders React throws away, and differs between the server and the browser.
Bad
// @flow
let renders = 0;
export component Counter() {
renders = renders + 1;
return <p>Rendered {renders} times</p>;
}
uf lint reports:
app/example.js:5:3 Cannot reassign variables declared outside of the component/hook. Variable `renders` is declared outside of the component/hook. Reassigning this value during render is a form of side effect, which can cause unpredictable behavior depending on when the component happens to re-render. If this variable is used in rendering, use useState instead. Otherwise, consider updating it in an effect. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render).
Good
// @flow
import { useRef, useEffect } from "react";
export component Counter() {
const renders = useRef(0);
useEffect(() => {
renders.current += 1;
});
return <p>Counting renders in an effect</p>;
}
react-compiler/hooks
Default: error. The React Compiler's Hooks diagnostics: the Rules of Hooks.
The Rules of Hooks: call hooks at the top level of a component or hook, in the same order on every render. React matches each call to its state by position, so a hook behind a condition hands one hook's state to another.
Bad
// @flow
import { useState } from "react";
export component Search(enabled: boolean) {
if (!enabled) {
return null;
}
const [query, setQuery] = useState("");
return <input value={query} onChange={(event) => setQuery(event.currentTarget.value)} />;
}
uf lint reports:
app/example.js:8:29 Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
Good
// @flow
import { useState } from "react";
export component Search(enabled: boolean) {
const [query, setQuery] = useState("");
if (!enabled) {
return null;
}
return <input value={query} onChange={(event) => setQuery(event.currentTarget.value)} />;
}
react-compiler/immutability
Default: error. The React Compiler's Immutability diagnostics: props, state or another value React treats as immutable, mutated.
Props, state, context and values returned from hooks belong to React and must not be mutated. A mutation changes a value that React and the compiler assume did not change, so the screen and the data disagree. Copy the value and change the copy.
Bad
// @flow
export component Name(user: { name: string }) {
user.name = user.name.trim();
return <p>{user.name}</p>;
}
uf lint reports:
app/example.js:3:3 This value cannot be modified. Modifying component props or hook arguments is not allowed. Consider using a local variable instead.
Bad
A context value is shared by every component that reads it.
// @flow
import { useContext } from "react";
import { Settings } from "./settings.js";
export component Theme() {
const settings = useContext(Settings);
settings.theme = "dark";
return <p>{settings.theme}</p>;
}
uf lint reports:
app/example.js:7:3 This value cannot be modified. Modifying a value returned from 'useContext()' is not allowed.
Good
// @flow
export component Name(user: { name: string }) {
const name = user.name.trim();
return <p>{name}</p>;
}
react-compiler/incompatible-library
Default: warn. The React Compiler's IncompatibleLibrary diagnostics: an API known to break memoization.
Some libraries return functions or objects whose results change without their identity changing, which memoization cannot see. The compiler knows which APIs these are and skips memoizing the code that uses them. This reports that it did, so a missing update is not a mystery.
Bad
// @flow
import { useVirtualizer } from "@tanstack/react-virtual";
export component Rows(count: number, parent: { current: HTMLElement | null }) {
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parent.current,
estimateSize: () => 32,
});
return <div>{virtualizer.getVirtualItems().length}</div>;
}
uf lint reports:
app/example.js:5:23 Use of incompatible library. This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized.
Good
// @flow
export component Rows(labels: $ReadOnlyArray<string>) {
return labels.map((label) => <div key={label}>{label}</div>);
}
react-compiler/invariant
Default: off. The React Compiler's Invariant diagnostics: an internal assumption of the compiler that did not hold.
An internal assumption of the React Compiler that did not hold: a bug in the compiler, not a rule your code broke. The function is left uncompiled and runs as written. Off by default; turn it on when you want to know which functions the compiler gave up on, or to report a compiler bug.
Bad
A named function expression that refers to itself trips an assertion in this compiler version.
// @flow
export component Countdown(from: number) {
const tick = function step(n: number): $ReadOnlyArray<number> {
return n <= 0 ? [] : [n, ...step(n - 1)];
};
return <p>{tick(from).join(", ")}</p>;
}
uf lint reports:
app/example.js:4:33 [InferMutationAliasingEffects] Expected value kind to be initialized. <unknown> step$8:TFunction.
Good
// @flow
function step(n: number): $ReadOnlyArray<number> {
return n <= 0 ? [] : [n, ...step(n - 1)];
}
export component Countdown(from: number) {
return <p>{step(from).join(", ")}</p>;
}
react-compiler/memo-dependencies
Default: warn. The React Compiler's MemoDependencies diagnostics: a useMemo or useCallback dependency array missing or adding a value.
A useMemo or useCallback recomputes when one of its dependencies changes. A value it reads but does not list leaves it returning a stale result; a value listed but not read recomputes it for nothing.
Bad
// @flow
import { useMemo } from "react";
export component Total(price: number, quantity: number) {
const total = useMemo(() => price * quantity, [price]);
return <output>{total}</output>;
}
uf lint reports:
app/example.js:5:39 Found missing memoization dependencies. Missing dependencies can cause a value to update less often than it should, resulting in stale UI.
Good
// @flow
import { useMemo } from "react";
export component Total(price: number, quantity: number) {
const total = useMemo(() => price * quantity, [price, quantity]);
return <output>{total}</output>;
}
react-compiler/no-deriving-state-in-effects
Default: off. The React Compiler's EffectDerivationsOfState diagnostics: a value derived from props or state, computed in an effect.
State computed from props or other state in an effect renders twice: once with the stale value, then again with the new one. Compute the value during render instead. Off by default, as in eslint-plugin-react-hooks.
Bad
// @flow
import { useEffect, useState } from "react";
export component Name(first: string, last: string) {
const [full, setFull] = useState("");
useEffect(() => {
setFull(`${first} ${last}`);
}, [first, last]);
return <p>{full}</p>;
}
uf lint reports:
app/example.js:7:5 Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)
Good
// @flow
export component Name(first: string, last: string) {
const full = `${first} ${last}`;
return <p>{full}</p>;
}
react-compiler/preserve-manual-memoization
uf does not report this yet. It runs, but under the options uf and eslint-plugin-react-hooks run the compiler with, the shapes that break a manual memo are reported first as react-compiler/memo-dependencies or react-compiler/immutability, and compilation of that function stops before this check runs. The Bad example below is what the category is about.
Default: error. The React Compiler's PreserveManualMemo diagnostics: a useMemo or useCallback the compiler cannot preserve.
The compiler keeps every useMemo and useCallback it finds, so code that relies on a memoized value keeping its identity still works. When a dependency is changed after the memo reads it, the compiler cannot promise the same identity and skips the component instead of memoizing it differently.
Bad
// @flow
import { useMemo } from "react";
import { track } from "./track.js";
export component Count(a: number) {
const list = [a];
const size = useMemo(() => list.length, [list]);
track(list);
return <p>{size}</p>;
}
Good
// @flow
import { useMemo } from "react";
import { track } from "./track.js";
export component Count(a: number) {
const list = [a];
track(list);
const size = useMemo(() => list.length, [list]);
return <p>{size}</p>;
}
react-compiler/purity
Default: error. The React Compiler's Purity diagnostics: a known-impure function called during render.
Rendering must be pure: the same props and state give the same output. Date.now(), Math.random() and similar calls give a different answer on every render, and a different one again on the server.
Bad
// @flow
export component Stamp() {
const now = Date.now();
return <time>{now}</time>;
}
uf lint reports:
app/example.js:3:15 Cannot call impure function during render. `Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
Good
// @flow
export component Stamp(now: number) {
return <time>{now}</time>;
}
react-compiler/refs
Default: error. The React Compiler's Refs diagnostics: a ref read or written during render.
A ref is a value React does not track. Reading or writing ref.current during render shows whatever it held last time and does not update the screen when it changes. Use refs in effects and event handlers, and state for what is rendered.
Bad
// @flow
import { useRef } from "react";
export component Clicks() {
const count = useRef(0);
return <button type="button" onClick={() => { count.current += 1; }}>{count.current}</button>;
}
uf lint reports:
app/example.js:6:73 Cannot access refs during render. React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
Good
// @flow
import { useState } from "react";
export component Clicks() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
react-compiler/set-state-in-effect
Default: error. The React Compiler's EffectSetState diagnostics: setState called synchronously in an effect.
Setting state synchronously in an effect renders the component a second time straight after the first. Effects are for synchronizing with things outside React; derive the value during render, or set it from the event that caused it.
Bad
// @flow
import { useEffect, useState } from "react";
export component Filtered(items: $ReadOnlyArray<string>, query: string) {
const [visible, setVisible] = useState<$ReadOnlyArray<string>>([]);
useEffect(() => {
setVisible(items.filter((item) => item.includes(query)));
}, [items, query]);
return <p>{visible.length} matches</p>;
}
uf lint reports:
app/example.js:7:5 Calling setState synchronously within an effect can trigger cascading renders. Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following: * Update external systems with the latest state from React. * Subscribe for updates from some external system, calling setState in a callback function when external state changes. Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
Good
// @flow
export component Filtered(items: $ReadOnlyArray<string>, query: string) {
const visible = items.filter((item) => item.includes(query));
return <p>{visible.length} matches</p>;
}
react-compiler/set-state-in-render
Default: error. The React Compiler's RenderSetState diagnostics: setState called during render.
Setting state unconditionally during render starts another render, which sets it again: an infinite loop that React stops with an error.
Bad
// @flow
import { useState } from "react";
export component Greeting(name: string) {
const [text, setText] = useState("");
setText(`Hello, ${name}`);
return <p>{text}</p>;
}
uf lint reports:
app/example.js:6:3 Cannot call setState during render. Calling setState during render may trigger an infinite loop. * To reset state when other state/props change, store the previous value in state and update conditionally: https://react.dev/reference/react/useState#storing-information-from-previous-renders * To derive data from other state/props, compute the derived data during render without using state.
Good
// @flow
export component Greeting(name: string) {
const text = `Hello, ${name}`;
return <p>{text}</p>;
}
react-compiler/static-components
Default: error. The React Compiler's StaticComponents diagnostics: a component created during render.
A component created during render is a new component on every render, so React unmounts the old one and mounts the new one, throwing away its state and its DOM. Declare components at the top level of the module, and choose between existing ones instead of building one.
Bad
// @flow
export component Card(title: string) {
const Heading = () => <h2>{title}</h2>;
return (
<section>
<Heading />
</section>
);
}
uf lint reports:
app/example.js:6:8 Cannot create components during render. Components created during render will reset their state each time they are created. Declare components outside of render.
Bad
// @flow
import { makeField } from "./fields.js";
export component Field(kind: string) {
const Input = makeField(kind);
return <Input />;
}
uf lint reports:
app/example.js:6:11 Cannot create components during render. Components created during render will reset their state each time they are created. Declare components outside of render.
Good
// @flow
component Heading(title: string) {
return <h2>{title}</h2>;
}
export component Card(title: string) {
return (
<section>
<Heading title={title} />
</section>
);
}
react-compiler/syntax
Default: off. The React Compiler's Syntax diagnostics: code the compiler rejects as invalid syntax.
Code the React Compiler rejects as invalid, even though the parser accepted it. Off by default: flow/syntax and uf check report most of what a module gets wrong.
Bad
// @flow
export component Counter() {
const count = 0;
count = 1;
return <p>{count}</p>;
}
uf lint reports:
app/example.js:4:3 Cannot reassign a `const` variable. `count` is declared as const.
Good
// @flow
export component Counter() {
let count = 0;
count = 1;
return <p>{count}</p>;
}
react-compiler/todo
Default: off. The React Compiler's Todo diagnostics: code the compiler does not compile yet.
Code the React Compiler cannot compile yet. The function is left as it is, unmemoized, and the rest of the module is still compiled. Off by default: nothing is wrong with the code, and the finding says what the compiler skipped.
Bad
// @flow
export component Temperature(celsius: number) {
const reading = {
get fahrenheit(): number {
return (celsius * 9) / 5 + 32;
},
};
return <p>{reading.fahrenheit}°F</p>;
}
uf lint reports:
app/example.js:4:5 (BuildHIR::lowerExpression) Handle get functions in ObjectExpression
Good
// @flow
export component Temperature(celsius: number) {
const fahrenheit = (celsius * 9) / 5 + 32;
return <p>{fahrenheit}°F</p>;
}
react-compiler/unsupported-syntax
Default: warn. The React Compiler's UnsupportedSyntax diagnostics: syntax the compiler does not support.
Syntax the React Compiler will not compile, such as eval or with, whose effects on the surrounding variables it cannot see. The function is left uncompiled.
Bad
// @flow
export component Formula(source: string) {
const value = eval(source);
return <output>{String(value)}</output>;
}
uf lint reports:
app/example.js:3:17 The 'eval' function is not supported. Eval is an anti-pattern in JavaScript, and the code executed cannot be evaluated by React Compiler.
Good
// @flow
import { evaluate } from "./formula.js";
export component Formula(source: string) {
const value = evaluate(source);
return <output>{String(value)}</output>;
}
react-compiler/use-memo
Default: error. The React Compiler's UseMemo diagnostics: a useMemo callback that is async, a generator, or takes parameters.
useMemo calls its callback with no arguments and keeps what it returns. An async callback memoizes a promise, a generator memoizes an iterator, and a parameter is always undefined.
Bad
// @flow
import { useMemo } from "react";
export component Profile(id: string) {
const user = useMemo(async () => {
const response = await fetch(`/users/${id}`);
return response.json();
}, [id]);
return <pre>{String(user)}</pre>;
}
uf lint reports:
app/example.js:5:24 useMemo() callbacks may not be async or generator functions. useMemo() callbacks are called once and must synchronously return a value.
Good
// @flow
import { use, useMemo } from "react";
export component Profile(id: string) {
const user = useMemo(() => fetch(`/users/${id}`).then((response) => response.json()), [id]);
return <pre>{String(use(user))}</pre>;
}
react-compiler/void-use-memo
Default: error. The React Compiler's VoidUseMemo diagnostics: a useMemo callback that returns nothing.
A useMemo whose callback returns nothing memoizes undefined. It is usually an effect written in the wrong hook, or a missing return.
Bad
// @flow
import { useMemo } from "react";
export component Total(prices: $ReadOnlyArray<number>) {
const total = useMemo(() => {
prices.reduce((sum, price) => sum + price, 0);
}, [prices]);
return <output>{String(total)}</output>;
}
uf lint reports:
app/example.js:5:25 useMemo() callbacks must return a value. This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects.
Good
// @flow
import { useMemo } from "react";
export component Total(prices: $ReadOnlyArray<number>) {
const total = useMemo(() => {
return prices.reduce((sum, price) => sum + price, 0);
}, [prices]);
return <output>{total}</output>;
}
a11y/alt-text
Default: error. img, area and input type="image" need an alt.
An image with no alt is announced by its file name, or not at all, so a screen reader user loses whatever the image says (WCAG 1.1.1). Give img, area and input type="image" an alt with the words the image carries, or alt="" when it is decoration.
Bad
// @flow
export component Avatar() {
return (
<img src="/avatars/ada.png" />
);
}
uf lint reports:
app/example.js:4:5 `<img>` has no `alt`, so a screen reader announces its URL or nothing at all; give it the words the image is carrying, or `alt=""` when it carries none and the page already says them
Good
// @flow
export component Avatar() {
return (
<img src="/avatars/ada.png" alt="Ada Lovelace" />
);
}
a11y/anchor-ambiguous-text
Default: warn. Link text says where the link goes, not "click here".
Screen reader users skim a page through its list of links, where each link is heard without the sentence around it. "Click here" or "read more" says nothing in that list (WCAG 2.4.4). Make the link text say where it goes.
Bad
// @flow
export component Pricing() {
return (
<p>
Plans start at $5. <a href="/pricing">Click here</a>
</p>
);
}
uf lint reports:
app/example.js:5:26 link text "click here" does not say where the link goes, and a screen reader's list of links reads it with nothing around it (WCAG 2.4.4); name the destination instead, such as `<a>read the pricing guide</a>`
Good
// @flow
export component Pricing() {
return (
<p>
Plans start at $5. <a href="/pricing">See every plan</a>
</p>
);
}
a11y/anchor-has-content
Default: error. An a needs text or a label a screen reader can announce.
A link with no text and no label is announced as "link" and nothing more, so nobody using a screen reader can tell where it goes (WCAG 2.4.4, 4.1.2).
Bad
// @flow
export component Home() {
return (
<a href="/" />
);
}
uf lint reports:
app/example.js:4:5 this `<a>` is empty, so a screen reader reads it as "link" and nothing more, and WAI-ARIA requires a link to have a name; put the words in it, or name it with `aria-label` when it holds only an icon
Good
// @flow
export component Home() {
return (
<a href="/">Home</a>
);
}
a11y/anchor-is-valid
Default: error. An a needs a real href; an action belongs on a button.
An a without a real href is not a link. It is not in the tab order, it opens nothing in a new tab, and it is announced as something it is not. Something that performs an action is a button.
Bad
// @flow
export component Menu(onOpen: () => void) {
return (
<a href="#" onClick={onOpen}>
Open menu
</a>
);
}
uf lint reports:
app/example.js:4:8 `href="#"` jumps to the top of the page, so with an `onClick` this `<a>` is a button that a screen reader calls a link, and Space, the key that presses a button, scrolls the page instead; make it a `<button type="button">`, or give it a real destination
Good
// @flow
export component Menu(onOpen: () => void) {
return (
<button type="button" onClick={onOpen}>
Open menu
</button>
);
}
a11y/aria-activedescendant-has-tabindex
Default: error. aria-activedescendant needs an element that can take focus.
aria-activedescendant tells assistive technology which option is current while focus stays on the container. That only works when the container can take focus, so an element that is not focusable by default needs a tabIndex.
Bad
// @flow
export component Options() {
return (
<div role="listbox" aria-activedescendant="option-1">
<div id="option-1" role="option" aria-selected="true">
One
</div>
</div>
);
}
uf lint reports:
app/example.js:4:25 `aria-activedescendant` says which element focus is standing in for, and `<div>` cannot take focus, so nothing ever reads it; give it `tabIndex={0}`
Good
// @flow
export component Options() {
return (
<div role="listbox" aria-activedescendant="option-1" tabIndex={0}>
<div id="option-1" role="option" aria-selected="true">
One
</div>
</div>
);
}
a11y/aria-props
Default: error. An aria-* attribute must be one ARIA defines.
An aria-* attribute that ARIA does not define is ignored by every browser, so the state or name it was meant to carry never reaches assistive technology. It is usually a typo.
Bad
// @flow
export component Close() {
return (
<button type="button" aria-lable="Close">
×
</button>
);
}
uf lint reports:
app/example.js:4:27 `aria-lable` is not an ARIA attribute, so nothing reads it: the browser keeps it, no assistive technology looks at it, and the element stays unlabelled with no symptom to notice; did you mean `aria-label`?
Good
// @flow
export component Close() {
return (
<button type="button" aria-label="Close">
×
</button>
);
}
a11y/aria-proptypes
Default: error. An aria-* value must be one its attribute takes.
Each aria-* attribute takes a fixed kind of value: true or false, a token from a list, an id. A value outside that set is ignored, so the state a screen reader should announce is lost.
Bad
// @flow
export component Nav() {
return (
<a href="/docs" aria-current="yes">
Docs
</a>
);
}
uf lint reports:
app/example.js:4:21 `aria-current` takes one of `page`, `step`, `location`, `date`, `time`, `true`, `false`, and `yes` is not one, so the attribute is dropped and the state it was written for is never announced (WCAG 4.1.2)
Good
// @flow
export component Nav() {
return (
<a href="/docs" aria-current="page">
Docs
</a>
);
}
a11y/aria-role
Default: error. A role must be a real, non-abstract ARIA role.
A role that is not an ARIA role, or is one of the abstract roles ARIA uses only to organise its own taxonomy, is ignored, and the element is announced as whatever it was before.
Bad
// @flow
export component Toolbar() {
return (
<div role="toolbox">
<button type="button">Bold</button>
</div>
);
}
uf lint reports:
app/example.js:4:10 `toolbox` is not an ARIA role, so nothing reads it: the browser keeps the attribute, no assistive technology looks at it, and the element is announced as whatever HTML made it (WCAG 4.1.2); did you mean `toolbar`?
Good
// @flow
export component Toolbar() {
return (
<div role="toolbar">
<button type="button">Bold</button>
</div>
);
}
a11y/aria-unsupported-elements
Default: error. The elements ARIA reserves take no role and no aria-*.
Elements such as meta, script, style and html are never rendered as content, so ARIA has nothing to describe on them. A role or aria-* there does nothing and suggests it does.
Bad
// @flow
export component Head() {
return (
<meta charSet="utf-8" aria-hidden="true" />
);
}
uf lint reports:
app/example.js:4:27 `<meta>` is one of the elements ARIA reserves: it is never rendered, so it is not in the accessibility tree and `aria-hidden` on it is read by nothing (WCAG 4.1.2); put it on the element a reader actually reaches
Good
// @flow
export component Head() {
return (
<meta charSet="utf-8" />
);
}
a11y/autocomplete-valid
Default: error. An autofill token the browser knows is what fills the field.
Browsers and password managers fill a field from its autoComplete token, and assistive tools use it to show a familiar icon (WCAG 1.3.5). A token the HTML standard does not define fills nothing.
Bad
// @flow
export component Email() {
return (
<input type="email" name="email" autoComplete="mail" />
);
}
uf lint reports:
app/example.js:4:38 `mail` is not an autofill token, so the browser cannot tell what this field is for and fills nothing — which WCAG 1.3.5 asks for, and which matters most to somebody for whom typing an address again is the hard part; use a token from the HTML autofill list, such as `name`, `email`, `street-address` or `cc-number`, or `off` on its own
Good
// @flow
export component Email() {
return (
<input type="email" name="email" autoComplete="email" />
);
}
a11y/click-events-have-key-events
Default: error. An element with a role and an onClick needs a key handler too.
An element with a role and an onClick is announced as a control, so keyboard users will try Enter or Space on it. Without a key handler nothing happens (WCAG 2.1.1).
Bad
// @flow
export component Toggle(onToggle: () => void) {
return (
<div role="button" tabIndex={0} onClick={onToggle}>
Toggle
</div>
);
}
uf lint reports:
app/example.js:4:37 `<div role="button">` answers a click and no key press, so a keyboard can reach it and still not use it; add an `onKeyDown` that runs the same handler
Good
A real button gets Enter, Space and focus from the browser.
// @flow
export component Toggle(onToggle: () => void) {
return (
<button type="button" onClick={onToggle}>
Toggle
</button>
);
}
a11y/control-has-associated-label
Default: warn. A control needs a name a screen reader can announce.
A button, input or other control with no text, no label and no aria-label is announced only by its role: "button". Nobody can tell what it does (WCAG 4.1.2).
Bad
// @flow
export component Search(onSearch: () => void) {
return (
<button type="button" onClick={onSearch} />
);
}
uf lint reports:
app/example.js:4:5 this `<button>` has no name and no `id` for a `<label>` to point at, so a screen reader announces only what kind of control it is; give it an `aria-label`, put the words inside it, or give it an `id` and point a `<label htmlFor>` at that
Good
// @flow
export component Search(onSearch: () => void) {
return (
<button type="button" aria-label="Search" onClick={onSearch} />
);
}
a11y/heading-has-content
Default: error. A heading needs text or a label a screen reader can announce.
An empty heading still shows up in a screen reader's list of headings, as a heading with nothing in it. Readers navigate by those headings (WCAG 2.4.6).
Bad
// @flow
export component Section() {
return (
<section>
<h2 />
<p>Nothing here yet.</p>
</section>
);
}
uf lint reports:
app/example.js:5:7 this `<h2>` has nothing a screen reader can announce, so a reader moving through the page by its headings lands on it and hears nothing; put the heading's words in it, or remove it
Good
// @flow
export component Section() {
return (
<section>
<h2>Drafts</h2>
<p>Nothing here yet.</p>
</section>
);
}
a11y/heading-order
Default: warn. Heading levels go down one at a time.
Headings are the outline a screen reader user navigates by. Jumping from h2 to h4 suggests a missing section, and readers go looking for it (WCAG 1.3.1).
Bad
// @flow
export component Article() {
return (
<article>
<h2>Setup</h2>
<h4>Install</h4>
</article>
);
}
uf lint reports:
app/example.js:6:7 heading level jumps from `<h2>` to `<h4>`, and a reader navigating by heading reads the gap as a section that is missing; use `<h3>` and give it the size you wanted with CSS
Good
// @flow
export component Article() {
return (
<article>
<h2>Setup</h2>
<h3>Install</h3>
</article>
);
}
a11y/html-has-lang
Default: error. html needs a lang.
Without a lang on html, a screen reader reads the page in the user's default voice and pronunciation, which garbles text in any other language (WCAG 3.1.1).
Bad
// @flow
import * as React from "react";
export component Document(children: React.Node) {
return (
<html>
<body>{children}</body>
</html>
);
}
uf lint reports:
app/example.js:6:5 `<html>` names no language, so a screen reader reads the page with the voice and pronunciation of its user's own (WCAG 3.1.1); set `lang` to the language the page is written in, such as `lang="en"`
Good
// @flow
import * as React from "react";
export component Document(children: React.Node) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
a11y/iframe-has-title
Default: error. An iframe needs a title that says what it holds.
A screen reader announces an iframe by its title. Without one, the reader hears "frame" and has to go in to find out what it holds (WCAG 4.1.2).
Bad
// @flow
export component Map() {
return (
<iframe src="https://maps.example.com/embed?q=office" />
);
}
uf lint reports:
app/example.js:4:5 this `<iframe>` has no `title`, so a screen reader announces a frame with no name and a reader has to go inside to learn what it holds (WCAG 4.1.2); give it a `title` that says, such as `title="Map of the venue"`
Good
// @flow
export component Map() {
return (
<iframe src="https://maps.example.com/embed?q=office" title="Map of our office" />
);
}
a11y/img-redundant-alt
Default: warn. alt text does not call the image an image, photo or picture.
A screen reader already says "image" before an image's alt. Alt text that starts "image of" or "photo of" makes it say so twice, and adds nothing.
Bad
// @flow
export component Team() {
return (
<img src="/team.jpg" alt="Photo of the team at the offsite" />
);
}
uf lint reports:
app/example.js:4:26 this `alt` says "photo", but a screen reader already announces an `<img>` as an image, so a reader hears it twice; describe what the image shows, such as `alt="Ada at the summit"` rather than `alt="Photo of Ada at the summit"`
Good
// @flow
export component Team() {
return (
<img src="/team.jpg" alt="The team at the offsite" />
);
}
a11y/interactive-supports-focus
Default: error. A widget role has to be reachable by keyboard.
An element given a widget role, such as button, checkbox or tab, is announced as a control, but a div or span is not in the tab order. Keyboard users hear about a control they cannot reach (WCAG 2.1.1).
Bad
// @flow
export component Star(onStar: () => void) {
return (
<span role="button" onClick={onStar} onKeyDown={onStar}>
★
</span>
);
}
uf lint reports:
app/example.js:4:11 `role="button"` is a widget and `<span>` cannot take focus, so a keyboard never reaches the handler on it; give it `tabIndex={0}`, or use the element that is already this role
Good
// @flow
export component Star(onStar: () => void) {
return (
<span role="button" tabIndex={0} onClick={onStar} onKeyDown={onStar}>
★
</span>
);
}
a11y/label-has-associated-control
Default: error. A label must name a control, by htmlFor or by holding it.
A label that points at no control gives nothing its name: the field is announced without one, and clicking the label does not focus it (WCAG 1.3.1, 4.1.2). Point it at the control with htmlFor, or put the control inside it.
Bad
// @flow
export component Name() {
return (
<div>
<label>Name</label>
<input id="name" type="text" />
</div>
);
}
uf lint reports:
app/example.js:5:7 this `<label>` is attached to no control, so clicking it does nothing and the field it describes has no accessible name; point `htmlFor` at the control's `id`, or put the control inside the label
Good
// @flow
export component Name() {
return (
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" />
</div>
);
}
a11y/lang
Default: error. The lang on html has to be a language tag.
The lang on html decides the voice and pronunciation a screen reader uses. A value that is not a BCP 47 language tag is ignored, which is the same as having none (WCAG 3.1.1).
Bad
// @flow
import * as React from "react";
export component Document(children: React.Node) {
return (
<html lang="en_GB">
<body>{children}</body>
</html>
);
}
uf lint reports:
app/example.js:6:11 `lang="en_GB"` is not a language tag, so a screen reader cannot tell what to pronounce this page as and carries on in whatever voice it was already using; write a BCP 47 tag such as `en`, `en-GB` or `ja`
Good
// @flow
import * as React from "react";
export component Document(children: React.Node) {
return (
<html lang="en-GB">
<body>{children}</body>
</html>
);
}
a11y/media-has-caption
Default: warn. audio and video need a captions track.
Deaf and hard-of-hearing viewers rely on captions for anything a video or recording says (WCAG 1.2.2). Give audio and video a track of kind captions.
Bad
// @flow
export component Intro() {
return (
<video src="/intro.mp4" controls />
);
}
uf lint reports:
app/example.js:4:5 this `<video>` has no `<track kind="captions">`, so whoever cannot hear it misses what is said (WCAG 1.2.2); add a captions track, or `muted` if it has no sound to caption
Good
// @flow
export component Intro() {
return (
<video src="/intro.mp4" controls>
<track kind="captions" src="/intro.en.vtt" srcLang="en" />
</video>
);
}
a11y/mouse-events-have-key-events
Default: error. onMouseOver and onMouseOut need onFocus and onBlur beside them.
Something that happens on hover has to happen on focus too, or keyboard users never see it (WCAG 2.1.1). onMouseOver needs onFocus and onMouseOut needs onBlur.
Bad
// @flow
export component Hint(onShow: () => void, onHide: () => void) {
return (
<button type="button" onMouseOver={onShow} onMouseOut={onHide}>
?
</button>
);
}
uf lint reports:
app/example.js:4:27 `onMouseOver` fires for a pointer and nothing else, so whatever it does never happens for a keyboard; add `onFocus`, which is the same moment for somebody tabbing through
app/example.js:4:48 `onMouseOut` fires for a pointer and nothing else, so whatever it does never happens for a keyboard; add `onBlur`, which is the same moment for somebody tabbing through
Good
// @flow
export component Hint(onShow: () => void, onHide: () => void) {
return (
<button
type="button"
onMouseOver={onShow}
onMouseOut={onHide}
onFocus={onShow}
onBlur={onHide}
>
?
</button>
);
}
a11y/no-access-key
Default: error. accessKey asks for a shortcut the browser has already given out.
accessKey claims a keyboard shortcut that the browser, the operating system or a screen reader has usually already taken, and it is not announced anywhere a user would find it.
Bad
// @flow
export component Save() {
return (
<button type="submit" accessKey="s">
Save
</button>
);
}
uf lint reports:
app/example.js:4:27 `accessKey` asks for a keyboard shortcut the browser and the screen reader have already given out, and the modifier that reaches it differs per browser, per platform and per assistive technology; it either does nothing or takes a shortcut away from somebody relying on it, so drop it and give the control a visible, documented way in
Good
// @flow
export component Save() {
return (
<button type="submit">Save</button>
);
}
a11y/no-aria-hidden-on-focusable
Default: error. aria-hidden does not belong on an element that takes focus.
aria-hidden removes an element from what a screen reader announces, but not from the tab order. A focusable element hidden that way takes focus and is announced as nothing (WCAG 4.1.2).
Bad
// @flow
export component Close(onClose: () => void) {
return (
<button type="button" aria-hidden="true" onClick={onClose}>
×
</button>
);
}
uf lint reports:
app/example.js:4:27 `<button>` is hidden from assistive technology but still takes focus, so a keyboard lands on an element a screen reader cannot announce; drop the `aria-hidden`, or take it out of the tab order with `tabIndex={-1}`
Good
Hide the decorative glyph, not the control.
// @flow
export component Close(onClose: () => void) {
return (
<button type="button" aria-label="Close" onClick={onClose}>
<span aria-hidden="true">×</span>
</button>
);
}
a11y/no-autofocus
Default: error. autoFocus moves focus before the reader knows where they are.
autoFocus moves focus as the page loads, before the user knows where they are. A screen reader starts reading from the middle of the page and skips everything above the field (WCAG 3.2.1).
Bad
// @flow
export component Search() {
return (
<input type="search" aria-label="Search" autoFocus />
);
}
uf lint reports:
app/example.js:4:46 `autoFocus` moves focus before the reader has been told where they are: a screen reader begins at this control instead of the top of the page, so the heading that says what the page is never gets read, and somebody using magnification is moved somewhere they did not ask to go; focus it from an event the reader caused instead
Good
// @flow
export component Search() {
return (
<input type="search" aria-label="Search" />
);
}
a11y/no-distracting-elements
Default: error. marquee and blink move with no way for the reader to stop them.
marquee and blink move or flash with no way to pause them, which makes the page hard to read and can be harmful (WCAG 2.2.2).
Bad
// @flow
export component Banner() {
return (
<marquee>Sale ends tonight</marquee>
);
}
uf lint reports:
app/example.js:4:5 `<marquee>` animates on its own and gives the reader no way to stop it, which WCAG 2.2.2 requires for anything that moves for more than five seconds; HTML removed the element and browsers still render it, so this keeps working and keeps being a problem — drop it, or animate with CSS the reader can turn off through `prefers-reduced-motion`
Good
// @flow
export component Banner() {
return (
<p>Sale ends tonight</p>
);
}
a11y/no-interactive-element-to-noninteractive-role
Default: error. A control keeps its behaviour, so it must keep its announcement.
A button or a link keeps its keyboard behaviour whatever role it is given. Relabel it as something inert, such as presentation, and it still takes focus and still acts, but a screen reader no longer announces it as a control.
Bad
// @flow
export component RemoveTag(onRemove: () => void) {
return (
<button type="button" role="presentation" onClick={onRemove}>
×
</button>
);
}
uf lint reports:
app/example.js:4:27 `<button>` is a control a keyboard reaches and `role="presentation"` is not, so it keeps the behaviour and loses the announcement: focus lands on something a screen reader calls presentation; drop the role, or use an element that is one
Good
// @flow
export component RemoveTag(onRemove: () => void) {
return (
<button type="button" aria-label="Remove tag" onClick={onRemove}>
×
</button>
);
}
a11y/no-noninteractive-element-interactions
Default: error. Handlers belong on something announced as a control.
Handlers on an element announced as content, such as an article or a paragraph, are never announced as anything to act on: a screen reader user has no reason to press a key there. Put the behaviour on a control.
Bad
// @flow
export component Story(onOpen: () => void) {
return (
<article onClick={onOpen} onKeyDown={onOpen}>
Quarterly report
</article>
);
}
uf lint reports:
app/example.js:4:5 `<article>` has the role `article` and is not a control, so handlers on it are reachable but never announced as anything to act on; move them to a `<button>`, or give the element a role that says what it does
Good
// @flow
export component Story(onOpen: () => void) {
return (
<article>
<button type="button" onClick={onOpen}>
Quarterly report
</button>
</article>
);
}
a11y/no-noninteractive-element-to-interactive-role
Default: error. An element with its own semantics is not a control to relabel.
Elements such as h2, ul or article already mean something to a screen reader. Giving one a control role such as button tells the reader one thing while the markup is another, and the control still needs every behaviour a real one has. Put the control element inside it.
Bad
// @flow
export component SectionToggle(onToggle: () => void) {
return (
<h2 role="button" tabIndex={0} onClick={onToggle} onKeyDown={onToggle}>
Shipping
</h2>
);
}
uf lint reports:
app/example.js:4:9 `<h2>` already has the role `heading` and `role="button"` says it is a control, so a screen reader is told one thing and the markup does another; build the control out of an element that has no semantics of its own, or use the element whose own role is `button`
Good
// @flow
export component SectionToggle(open: boolean, onToggle: () => void) {
return (
<h2>
<button type="button" aria-expanded={open} onClick={onToggle}>
Shipping
</button>
</h2>
);
}
a11y/no-noninteractive-tabindex
Default: warn. A tabIndex belongs on a control, not on what is only read.
A tabIndex on content that does nothing adds a stop to the tab order that leads nowhere, and keyboard users have to walk through it on every pass.
Bad
// @flow
export component Notice() {
return (
<p tabIndex={0}>Your changes were saved.</p>
);
}
uf lint reports:
app/example.js:4:8 `<p>` is not a control, so this `tabIndex` adds a stop with nothing to do at it; drop it, or use `tabIndex={-1}` if something focuses this element itself
Good
If the text must be announced, make it a live region instead of a tab stop.
// @flow
export component Notice() {
return (
<p role="status">Your changes were saved.</p>
);
}
a11y/no-redundant-roles
Default: error. A role the element already has says nothing twice.
A role the element already has, such as role="button" on a button, changes nothing and makes the reader of the code wonder what it was for.
Bad
// @flow
export component Save() {
return (
<button type="submit" role="button">
Save
</button>
);
}
uf lint reports:
app/example.js:4:27 a `<button>` is already a `button`, so `role="button"` tells the browser what it told the browser: ARIA's first rule is to use the element and not to repeat it, and the copy is one more thing to keep true when the markup changes; drop the attribute
Good
// @flow
export component Save() {
return (
<button type="submit">Save</button>
);
}
a11y/no-static-element-interactions
Default: error. An onClick needs an element a keyboard can reach.
A div or span with an onClick cannot be reached with the keyboard and is announced as nothing in particular, so the action it performs is available only to a mouse (WCAG 2.1.1).
Bad
// @flow
export component Card(onOpen: () => void) {
return (
<div onClick={onOpen}>Open the report</div>
);
}
uf lint reports:
app/example.js:4:5 `<div>` has an `onClick` but no `role` and no key handler, so nothing but a mouse can reach it; make it a `<button>`, or give it a `role` and an `onKeyDown`
Good
// @flow
export component Card(onOpen: () => void) {
return (
<button type="button" onClick={onOpen}>
Open the report
</button>
);
}
a11y/prefer-tag-over-role
Default: warn. An element that is the role beats a role that says it.
An element that is the role, such as button, nav or h2, comes with the keyboard behaviour and the semantics the role only promises. A div with a role needs all of that written by hand.
Bad
// @flow
export component Links() {
return (
<div role="navigation">
<a href="/docs">Docs</a>
</div>
);
}
uf lint reports:
app/example.js:4:10 `role="navigation"` names what `<nav>` already is: the element carries the role without being told, keeps it when somebody moves or copies the markup, and brings the rest of its behaviour with it; write `<nav>` instead of a `<div>` with a role
Good
// @flow
export component Links() {
return (
<nav>
<a href="/docs">Docs</a>
</nav>
);
}
a11y/role-has-required-aria-props
Default: error. A role must be given the aria-* state it is announced by.
Some roles are announced by their state: a checkbox by whether it is checked, a slider by its value. Without the attribute that carries that state, the reader hears the role and nothing else.
Bad
// @flow
export component Remember(onToggle: () => void) {
return (
<span role="checkbox" tabIndex={0} onClick={onToggle} onKeyDown={onToggle}>
Remember me
</span>
);
}
uf lint reports:
app/example.js:4:11 `role="checkbox"` tells a screen reader this is a checkbox, and `aria-checked` is the state a checkbox is announced by: without it the control is read out with nothing to say whether it is on, off or anywhere in between (WCAG 4.1.2); add it, or drop the role and use the HTML element that carries the state itself
Good
// @flow
export component Remember(checked: boolean, onToggle: () => void) {
return (
<span
role="checkbox"
aria-checked={checked}
tabIndex={0}
onClick={onToggle}
onKeyDown={onToggle}
>
Remember me
</span>
);
}
a11y/role-supports-aria-props
Default: error. An element carries only the aria-* its role takes.
Each role takes a fixed set of aria-* states. One its role does not take, such as aria-checked on a link, is ignored, so whatever state it was meant to carry is never announced.
Bad
// @flow
export component Nav() {
return (
<a href="/inbox" aria-checked="true">
Inbox
</a>
);
}
uf lint reports:
app/example.js:4:22 a `<a>` is a `link`, and ARIA gives `link` no `aria-checked`, so the attribute sits in the DOM with nothing to read it and the state it describes is never announced (WCAG 4.1.2); remove it, or give the element the role this state belongs to
Good
// @flow
export component Nav() {
return (
<a href="/inbox" aria-current="page">
Inbox
</a>
);
}
a11y/scope
Default: error. scope belongs on a th and is ignored anywhere else.
scope tells a screen reader which cells a header labels. It means that only on th; anywhere else it is ignored (WCAG 1.3.1).
Bad
// @flow
export component Prices() {
return (
<table>
<tbody>
<tr>
<td scope="row">Basic</td>
<td>$5</td>
</tr>
</tbody>
</table>
);
}
uf lint reports:
app/example.js:7:15 HTML defines `scope` on `<th>` and nowhere else, so on a `<td>` it is ignored and the header it was meant to describe is not announced as a header at all; make this a `<th scope="col">` or `<th scope="row">`, which is what lets a screen reader read a data table cell by cell
Good
// @flow
export component Prices() {
return (
<table>
<tbody>
<tr>
<th scope="row">Basic</th>
<td>$5</td>
</tr>
</tbody>
</table>
);
}
a11y/tabindex-no-positive
Default: error. A tabIndex above zero reorders the whole page.
A tabIndex above zero moves the element ahead of everything else in the tab order, across the whole page. Keyboard users then jump around the page in an order that matches nothing they see (WCAG 2.4.3).
Bad
// @flow
export component Login() {
return (
<input type="email" aria-label="Email" tabIndex={1} />
);
}
uf lint reports:
app/example.js:4:44 a `tabIndex` of 1 puts this element ahead of everything the document orders itself, so the tab order stops matching the reading order; give it `tabIndex={0}` and let its position in the markup decide
Good
// @flow
export component Login() {
return (
<input type="email" aria-label="Email" />
);
}
markup/no-invalid-nesting
Default: error. Nest elements the HTML parser will leave where you wrote them.
The HTML parser moves elements it does not allow where they were written: a div inside a p closes the paragraph, an a inside an a closes the outer link. The server's HTML then no longer matches the tree React built, and hydration fails or patches the page.
Bad
// @flow
export component Note() {
return (
<p>
<div>Saved.</div>
</p>
);
}
uf lint reports:
app/example.js:5:7 `<div>` inside `<p>` is not markup a browser keeps: the parser closes the `<p>` before it. React renders one tree and the parser builds another, which is a hydration mismatch rather than a matter of taste
Good
// @flow
export component Note() {
return (
<div>
<p>Saved.</p>
</div>
);
}
markup/no-invalid-rel
Default: warn. A rel keyword has to be one this element takes.
Each rel keyword is defined for particular elements: stylesheet for link, nofollow for a and area. A keyword the element does not take is ignored, so the behaviour it was written for, such as noopener, silently does not happen.
Bad
// @flow
export component Styles() {
return (
<a href="/theme.css" rel="stylesheet">
Theme
</a>
);
}
uf lint reports:
app/example.js:4:26 `stylesheet` is a link type, but not one a `<a>` takes, so the browser ignores it and this element carries the relationship it was meant to declare nowhere. Drop it, or put it on the element the keyword belongs to
Good
// @flow
export component Styles() {
return (
<link href="/theme.css" rel="stylesheet" />
);
}
import/no-absolute-path
Default: error. Imports must name packages or project-relative paths, not filesystem roots.
An import that starts at the filesystem root, such as /Users/me/app/format.js or C:\work\app\format.js, only resolves on the machine that wrote it. Import packages by name and project files by a relative path.
Bad
// @flow
import { format } from "/Users/me/app/lib/format.js";
uf lint reports:
app/example.js:2:24 import paths must be portable; use a package name or a relative path
Good
// @flow
import { format } from "./lib/format.js";
import { useState } from "react";
import/no-cycle
Default: error. Relative import graphs must not cycle back to the importing module.
Two modules that import each other, directly or through others, are evaluated in an order that depends on which one is imported first. Whichever runs second finds the first one's exports still uninitialized. Break the cycle by moving what both need into a third module.
Bad
cart.js imports pricing.js, which imports cart.js back.
app/cart.js
// @flow
import { priceOf } from "./pricing.js";
export type Line = { readonly sku: string, readonly quantity: number };
export const total = (lines: $ReadOnlyArray<Line>): number =>
lines.reduce((sum, line) => sum + priceOf(line), 0);
app/pricing.js
// @flow
import type { Line } from "./cart.js";
import { total } from "./cart.js";
export const priceOf = (line: Line): number => line.quantity * 100;
export const average = (lines: $ReadOnlyArray<Line>): number => total(lines) / lines.length;
uf lint reports:
app/cart.js:2:25 this import creates a cycle through `app/pricing.js`
app/pricing.js:3:23 this import creates a cycle through `app/cart.js`
Good
The shared type moves to its own module, and pricing.js no longer reaches back into cart.js.
app/cart.js
// @flow
import type { Line } from "./line.js";
import { priceOf } from "./pricing.js";
export const total = (lines: $ReadOnlyArray<Line>): number =>
lines.reduce((sum, line) => sum + priceOf(line), 0);
app/pricing.js
// @flow
import type { Line } from "./line.js";
export const priceOf = (line: Line): number => line.quantity * 100;
app/line.js
// @flow
export type Line = { readonly sku: string, readonly quantity: number };
import/no-deprecated
Default: error. Relative value imports must not use exports marked @deprecated.
An export whose doc comment says @deprecated is on its way out, and the comment usually names its replacement. Importing it adds one more caller that has to move when it goes.
Bad
app/api.js
// @flow
/** @deprecated Use `fetchUser`, which returns a typed result. */
export function getUser(id: string): Promise<mixed> {
return fetch(`/users/${id}`).then((response) => response.json());
}
export function fetchUser(id: string): Promise<{ readonly name: string }> {
return fetch(`/users/${id}`).then((response) => response.json());
}
app/page.js
// @flow
import { getUser } from "./api.js";
export const load = (id: string): Promise<mixed> => getUser(id);
uf lint reports:
app/page.js:2:10 `getUser` is deprecated by `app/api.js`; use a supported export instead
Good
app/api.js
// @flow
/** @deprecated Use `fetchUser`, which returns a typed result. */
export function getUser(id: string): Promise<mixed> {
return fetch(`/users/${id}`).then((response) => response.json());
}
export function fetchUser(id: string): Promise<{ readonly name: string }> {
return fetch(`/users/${id}`).then((response) => response.json());
}
app/page.js
// @flow
import { fetchUser } from "./api.js";
export const load = (id: string): Promise<{ readonly name: string }> => fetchUser(id);
import/no-duplicates
Default: error. Mergeable static imports from the same module belong together.
Two import statements from the same module can be written as one. Keeping them apart means the next reader has to look in two places to see what a module takes from another. A type import beside a value import is not a duplicate, since Flow keeps them apart.
Bad
// @flow
import { useState } from "react";
import { useEffect } from "react";
uf lint reports:
app/example.js:3:27 `react` is already imported in this file
Good
// @flow
import { useEffect, useState } from "react";
import type { Node } from "react";
import/no-extraneous-dependencies
Default: error. Bare package imports must be declared by the nearest package.json.
A package imported by name but not declared in the nearest package.json works only while something else happens to install it: a workspace neighbour, a hoisted transitive dependency. It breaks on the next clean install or in the published package. Declare every package you import.
Bad
package.json
{ "name": "shop", "dependencies": { "react": "^19.0.0" } }
app/page.js
// @flow
import { useState } from "react";
import { format } from "date-fns";
uf lint reports:
app/page.js:3:24 `date-fns` must be declared in the nearest package.json
Good
package.json
{ "name": "shop", "dependencies": { "date-fns": "^4.0.0", "react": "^19.0.0" } }
app/page.js
// @flow
import { useState } from "react";
import { format } from "date-fns";
import { readFile } from "node:fs/promises";
import { total } from "./cart.js";
import/no-named-as-default
Default: error. A default import must not be named like one of the module's named exports.
When a module exports Button by name and something else as its default, import Button from "./Button.js" reads as if it imports the named Button but actually gets the default export. Name the default import after what it is, or import the named export in braces.
Bad
app/Button.js
// @flow
export component Button(label: string) {
return <button type="button">{label}</button>;
}
export default component IconButton(icon: string) {
return <button aria-label={icon} type="button" />;
}
app/page.js
// @flow
import Button from "./Button.js";
uf lint reports:
app/page.js:2:8 `Button` is a named export of `app/Button.js`; import it by name instead of as the default
Good
app/Button.js
// @flow
export component Button(label: string) {
return <button type="button">{label}</button>;
}
export default component IconButton(icon: string) {
return <button aria-label={icon} type="button" />;
}
app/page.js
// @flow
import IconButton, { Button } from "./Button.js";
import/no-relative-packages
Default: error. Workspace packages should import each other by package name.
A relative path from one workspace package into another, such as ../../state/index.js from packages/web, skips the other package's exports and its declared dependencies. It stops working as soon as either package is published or moved. Import the package by its name.
Bad
packages/web/src/page.js
// @flow
import { createStore } from "../../state/index.js";
uf lint reports:
packages/web/src/page.js:2:29 use the `state` package name instead of a relative path into it
Good
packages/web/src/page.js
// @flow
import { createStore } from "@uniflowed/state";
import { Header } from "./header.js";
import/no-self-import
Default: error. A module must not import itself.
A module that imports itself gets its own half-initialized exports back, which is never what was meant. It is usually a leftover from moving code between files.
Bad
app/format.js
// @flow
import { pad } from "./format.js";
export const pad = (value: number): string => String(value).padStart(2, "0");
export const clock = (hours: number, minutes: number): string => `${pad(hours)}:${pad(minutes)}`;
uf lint reports:
app/format.js:2:21 a module must not import itself; move shared code to another module
Good
app/format.js
// @flow
export const pad = (value: number): string => String(value).padStart(2, "0");
export const clock = (hours: number, minutes: number): string => `${pad(hours)}:${pad(minutes)}`;
import/no-unused-modules
Default: off. Relative modules and value exports should be imported somewhere.
A module no other module imports, or an export nothing imports, is code that ships and has to be maintained without doing anything. Off by default, because entry points (routes, config files, scripts) are imported by the toolchain rather than by other modules. Switch it on to sweep a codebase for dead code.
Bad
legacy.js is imported by nothing, and lib.js's unused export is never imported.
app.js
// @flow
import { used } from "./lib.js";
used();
lib.js
// @flow
export function used() {}
export function unused() {}
legacy.js
// @flow
export const oldPrice = 100;
uf lint reports:
legacy.js:1:1 no relative import reaches this module
lib.js:2:1 unused export: `unused`
Good
app.js
// @flow
import { used } from "./lib.js";
used();
lib.js
// @flow
export function used() {}
import/no-useless-path-segments
Default: error. Relative imports should name the shortest equivalent path.
./features/../model.js is ./model.js with a detour. The longer path makes a reader resolve it in their head and hides what the module really depends on. Write the shortest path that names the same file.
Bad
app/page.js
// @flow
import { load } from "./features/../model.js";
uf lint reports:
app/page.js:2:22 `./features/../model.js` can be written as `./model.js`
Good
app/page.js
// @flow
import { load } from "./model.js";
import { routes } from "./routes/index.js";
server/no-client-secret
Default: error. Client modules must not read server secrets.
Everything a "use client" module reads ships to the browser. A server secret read there, such as a private token from process.env, ends up in the bundle anyone can download. Read secrets on the server and send the client only what it needs.
Bad
// @flow
"use client";
const token = process.env.PRIVATE_TOKEN;
export component Checkout() {
return <form data-token={token} />;
}
uf lint reports:
app/example.js:4:27 client modules must not read private server secrets
Good
app/checkout.server.js
// @flow
export async function charge(amount: number): Promise<void> {
const token = process.env.PRIVATE_TOKEN;
await fetch("https://payments.example/charge", {
body: JSON.stringify({ amount }),
headers: { authorization: `Bearer ${token ?? ""}` },
method: "POST",
});
}
server/no-server-only-import-in-client
Default: error. Client modules must not import server-only modules.
A client module is bundled for the browser along with everything it imports. Importing @uniflowed/server or a *.server.js module from one either fails the build or ships server code (database clients, file-system access) to the browser. Keep server work in server modules and pass results in as props or through server actions.
Bad
// @flow
"use client";
import { db } from "@uniflowed/server";
import { load } from "./orders.server.js";
uf lint reports:
app/example.js:4:21 client modules must not import server-only modules; move the call behind a server action
app/example.js:5:31 client modules must not import server-only modules; move the call behind a server action
Good
// @flow
"use client";
import { formatPrice } from "./format.js";
export component Price(cents: number) {
return <span>{formatPrice(cents)}</span>;
}
server/use-client-directive-position
Default: error. use client/use server must be the module's first statement.
"use client" and "use server" only mark a module when they are its first statement. Written after an import, they are ordinary string expressions: the module silently stays on the other side of the boundary. A "use server" inside a function body marks that function as a server action and is fine.
Bad
// @flow
import { useState } from "react";
"use client";
export component Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)} type="button">{count}</button>;
}
uf lint reports:
app/example.js:3:1 a boundary directive is only honoured as the module's first statement
Good
// @flow
"use client";
import { useState } from "react";
export component Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)} type="button">{count}</button>;
}
server/use-server-actions
Default: error. Server action modules must open with "use server";.
A module that defines server actions with serverAction has to open with "use server". Without it, the bundler does not replace the actions with references, and the action bodies (and everything they import) are bundled into the client.
Bad
app/actions.server.js
// @flow
import { serverAction } from "@uniflowed/server";
export const save = serverAction(async (title: string) => {
return { saved: title };
});
uf lint reports:
app/actions.server.js:1:1 server action modules must start with "use server";
Good
app/actions.server.js
"use server";
// @flow
import { serverAction } from "@uniflowed/server";
export const save = serverAction(async (title: string) => {
return { saved: title };
});
router/reserved-files
Default: error. $* file names are reserved for layout, page, and middleware.
In the app directory, a file name starting with $ is reserved for the router: $page, $layout, $middleware, $route, $not-found, $error and the rest. A $ name the router does not define, such as $handler.js, is not served as anything, and nothing says so at run time. Use one of the router's names, or a name without $ for your own module.
Bad
app/$handler.js
// @flow
export default function handler(): string {
return "hello";
}
uf lint reports:
app/$handler.js:1:1 reserved file names are $<layout|template|page|default|middleware|not-found|error|loading|route|story|instrumentation>[.<native|ios|android|web|test|client>].js
Good
app/api/$route.js
// @flow
export function GET(): Response {
return new Response("hello");
}
router/unsupported-segment
Default: error. (.)segment directories must be interceptions uf reads, inside a @slot.
An intercepting segment such as (.)photo renders a route in place of another, and uf supports it only inside a @slot, where there is somewhere to render it. Outside a slot, the directory is refused, where before it was silently treated as a literal URL segment. Move the interception under a slot such as @modal.
Bad
app/feed/(.)photo/$page.js
// @flow
export component Page() {
return <img alt="" src="/photo.jpg" />;
}
uf lint reports:
app/feed/(.)photo/$page.js:1:1 `(.)photo` is an intercepting route, and an intercepting route renders into a `@slot`: it is what a client navigation shows in a named place instead of the page its URL names, and outside a slot there is no named place for it to show in. It is refused rather than served as the URL segment `/(.)photo`, which is what it used to become. Move it inside a slot directory beside the layout that renders the slot, or rename the directory to the literal segment `photo`. https://github.com/ubugeeei-prod/uf/issues/267
Good
app/feed/@modal/(.)photo/$page.js
// @flow
export component Page() {
return <img alt="" src="/photo.jpg" />;
}
security/iframe-has-sandbox
Default: error. An iframe needs a sandbox to bound what the page inside it may do.
An iframe without sandbox gives the embedded page everything a top-level page can do: run scripts, submit forms, open pop-ups and navigate your page away. Add sandbox and grant back only what the embed needs; an empty sandbox grants nothing.
Bad
// @flow
export component Map() {
return <iframe src="https://maps.example/embed" title="Store location" />;
}
uf lint reports:
app/example.js:3:10 this `<iframe>` has no `sandbox`, so the document inside it runs with everything a document gets — its own scripts, forms and popups, and the whole of this page if it is served from this origin; add `sandbox` to take all of that away, then name back only what the frame needs, such as `sandbox="allow-scripts"`
Good
// @flow
export component Map() {
return (
<iframe sandbox="allow-scripts" src="https://maps.example/embed" title="Store location" />
);
}
security/no-dangerously-set-inner-html
Default: error. Render HTML only through a sanitizing @uniflowed/markdown helper.
dangerouslySetInnerHTML writes a string into the page as HTML, so any user-controlled text that reaches it is a cross-site scripting hole. Render text as children, which React escapes, and render Markdown or rich text through @uniflowed/markdown, which sanitizes.
Bad
// @flow
export component Comment(body: string) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
uf lint reports:
app/example.js:3:15 unsanitized HTML is an XSS sink; render it through a @uniflowed/markdown helper
Good
// @flow
export component Comment(body: string) {
return <p>{body}</p>;
}
security/no-eval
Default: error. Never turn strings into code via eval, new Function, or timer strings.
eval, new Function and a string passed to setTimeout or setInterval all compile text into code at run time. Any outside input that reaches that text runs with the page's full authority, and Content Security Policy has to be weakened to allow it at all. Pass a function instead.
Bad
// @flow
export function schedule(name: string): void {
setTimeout(`refresh("${name}")`, 1000);
eval(`window.${name}()`);
}
uf lint reports:
app/example.js:3:3 a string timer body is evaluated as code; pass a function instead
app/example.js:4:3 `eval` executes arbitrary code; parse the data instead
Good
// @flow
declare function refresh(name: string): void;
export function schedule(name: string): void {
setTimeout(() => refresh(name), 1000);
}
security/no-script-url
Default: error. A javascript: URL is a program in a prop the browser navigates to.
A javascript: URL in src, action, formAction or an area's href is a program the browser runs when the URL is loaded or followed. React 19 blocks it and warns, and before that it was a cross-site scripting hole. Use an event handler for an action and a real URL for a resource. A javascript: URL in an a element's href is reported by a11y/anchor-is-valid instead, so only one rule speaks about it.
Bad
// @flow
export component Search() {
return (
<form action="javascript:search()">
<input aria-label="Query" name="q" />
</form>
);
}
uf lint reports:
app/example.js:4:11 `action` on this `<form>` is a `javascript:` URL, which is not a place to go but a program to run, with everything the page can do; browsers and React both block it in more and more positions, and the moment any part of the string comes from outside the module it is an injection rather than a quirk — attach an `onClick` handler and give the element a real destination, or none at all
Good
// @flow
declare function search(data: FormData): void;
export component Search() {
return (
<form action={search}>
<input aria-label="Query" name="q" />
</form>
);
}
security/no-target-blank
Default: error. target="_blank" carries the page's URL on unless rel stops it.
A link that opens with target="_blank" sends the current page's URL along as the referrer unless rel stops it, and older browsers also hand the new page a window.opener handle back to yours. Add rel="noreferrer", which implies noopener.
Bad
// @flow
export component Docs() {
return (
<a href="https://docs.example.com" target="_blank">
Documentation
</a>
);
}
uf lint reports:
app/example.js:4:40 `<a target="_blank">` sends the `Referer` header on, so wherever this goes is told the full URL the reader is coming from — which in an application is a path with an order number, a document id or a search in it; add `rel="noreferrer"`, which stops it and covers `noopener` too (`noopener` on its own adds nothing, because `target="_blank"` already gives the opened page a null `window.opener`)
Good
// @flow
export component Docs() {
return (
<a href="https://docs.example.com" rel="noreferrer" target="_blank">
Documentation
</a>
);
}
fetch/no-global-override
Default: error. Do not monkey-patch global fetch.
uf's tracing, caching and request mocking are all built around the global fetch. Replacing it, as in globalThis.fetch = …, silently unhooks them for the whole process, including other packages. Wrap fetch in a function of your own, or use @uniflowed/mock in tests.
Bad
src/client.js
// @flow
const original = globalThis.fetch;
globalThis.fetch = (input, init) => original(input, { ...init, credentials: "include" });
uf lint reports:
src/client.js:3:1 do not override global fetch; use @uniflowed/fetch explicit clients
Good
src/client.js
// @flow
export function request(input: string, init?: RequestOptions): Promise<Response> {
return fetch(input, { ...init, credentials: "include" });
}
react-native/platform-split
Default: warn. Prefer platform-specific files over Platform.OS branches.
React Native picks Button.ios.js or Button.android.js at build time, so each platform ships only its own code. A Platform.OS branch ships both and decides at run time.
Bad
app/Button.js
// @flow
import { Platform } from "react-native";
export const radius: number = Platform.OS === "ios" ? 8 : 2;
uf lint reports:
app/Button.js:4:31 prefer platform-specific files for React Native platform branches
Good
app/Button.ios.js
// @flow
export const radius: number = 8;
app/Button.android.js
// @flow
export const radius: number = 2;
vite/hot-needs-optional-chaining
Default: error. Reach import.meta.hot through ?.; if does not refine it.
import.meta.hot is undefined outside the dev server. The usual Vite guard, if (import.meta.hot), does not refine it for Flow, because import.meta.hot is not a local variable, so uf check still reports the call inside. Reach it through ?., or copy it into a local variable first.
Bad
// @flow
if (import.meta.hot) {
import.meta.hot.accept();
}
uf lint reports:
app/example.js:3:3 `import.meta.hot` is undefined in a build, and `if (import.meta.hot)` does not refine it: Flow cannot key a refinement on `import.meta`. Write `import.meta.hot.accept` with `?.`, or bind `const hot = import.meta.hot;` first and test that
Good
// @flow
import.meta.hot?.accept();
const hot = import.meta.hot;
if (hot) {
hot.dispose(() => {});
}
package/no-npm-scripts
Default: error. Refuse install-time lifecycle scripts unless explicitly allowed.
Install-time lifecycle scripts (preinstall, install, postinstall, prepare) run arbitrary code on every machine that installs the package, which is how most npm supply-chain attacks spread. uf refuses them unless you allow them explicitly. Named scripts that only run when someone asks for them are fine.
Bad
{
"name": "shop",
"scripts": { "postinstall": "node scripts/setup.js" }
}
uf lint reports:
package.json:3:3 install-time lifecycle scripts (postinstall) are disabled; move the automation to uf tasks or explicitly allow lifecycle scripts
Good
{
"name": "shop",
"scripts": { "ios": "expo start --ios", "start": "expo start" }
}
uniflowed/no-npm-script-invocation
Default: error. Shell out to uf tasks, not npm run/yarn/pnpm/bunx.
A uf project declares its tasks in uf.config.js, where uf run caches them, orders their dependencies and runs them the same way in CI. Shelling out to npm run, yarn, pnpm or npx from source goes around all of that and ties the code to one package manager. Declare a task and run it with uf run.
Bad
// @flow
import { execSync } from "node:child_process";
execSync("npm run build");
execSync("pnpm install");
uf lint reports:
app/example.js:4:11 declare the task in uf.config.js; uf projects do not shell out to npm/yarn/pnpm/bunx
app/example.js:5:11 declare the task in uf.config.js; uf projects do not shell out to npm/yarn/pnpm/bunx
Good
// @flow
import { execSync } from "node:child_process";
execSync("uf run build");
// A manager named as data, not run: a union a config accepts.
export type Manager = "npm" | "pnpm" | "yarn";
uniflowed/no-tabs
Default: error. Indent with spaces, never tabs.
A tab is as wide as each editor, terminal and code review tool decides, so tab-indented code lines up differently for every reader. uf formats with spaces, and uf fmt fixes this. A tab inside a string is part of the string and is left alone.
Bad
// @flow
export function total(prices: $ReadOnlyArray<number>): number {
return prices.reduce((sum, price) => sum + price, 0);
}
uf lint reports:
app/example.js:3:1 replace tabs with spaces
Good
// @flow
export function total(prices: $ReadOnlyArray<number>): number {
return prices.reduce((sum, price) => sum + price, 0);
}
export const columns = "name\tprice";
uniflowed/no-trailing-whitespace
Default: error. Lines must not end in whitespace.
Whitespace at the end of a line is invisible in the editor and noisy in every diff that touches the line. uf fmt removes it. Spaces at the end of a line inside a template literal are part of the string and are left alone.
Bad
// @flow
export const greeting = "hello";
uf lint reports:
app/example.js:2:33 remove trailing whitespace
Good
// @flow
export const greeting = "hello";
uniflowed/unknown-lint-suppression
Default: error. uf-lint-disable comments must name a rule this linter knows.
A uf-lint-disable comment that names a rule uf does not know suppresses nothing, but it looks like it works. A typo such as flow/unclear-typo leaves the finding reported, or worse, leaves a later reader believing the line was reviewed. uf reports the unknown name, so a suppression either works or fails loudly.
Bad
// @flow
// uf-lint-disable-next-line flow/unclear-typo
export type Payload = { data: mixed };
uf lint reports:
app/example.js:2:30 unknown lint rule `flow/unclear-typo` in suppression comment
Good
// @flow
// The payload is validated by the caller, and Flow cannot express its shape.
// uf-lint-disable-next-line flow/unclear-type
export type Payload = { data: any };
uniflowed/unused-lint-suppression
Default: warn. A uf-lint-disable comment must silence a finding.
A uf-lint-disable comment whose rule finds nothing on the lines it covers suppresses nothing. It is usually left over from a finding that went away: the code changed, or the rule stopped reporting a false positive. Left in place, it would silence the next real finding on that line without anyone reading it. Delete it. Rules that are off, rules that need type inference, and (in an editor linting one file) the project-wide import/* rules are not judged, since they did not look.
Bad
// @flow
// uf-lint-disable-next-line flow/unclear-type
export type Payload = { data: mixed };
uf lint reports:
app/example.js:2:30 `flow/unclear-type` reports nothing here, so this suppression silences nothing; remove it
Good
// @flow
export type Payload = { data: mixed };Edit this pagedocs/app/reference/lint/$page.mdx