@ryanmab/biome-plugin-avoidable-anti-patterns
v0.0.4
Published
A collection of opinionated Biome rules for identifying and avoiding common code anti-patterns.
Readme
Biome: Avoidable Anti-Patterns
A collection of rules for Biome for identifying and avoiding anti-patterns in code.
These rules are largely inspired by online resources and existing plugins for other linters. Though many of the current rules focus on React Effects, other common anti-patterns are welcome where they can be reasonably detected without introducing too many false positives.
In some ways, this repository is an experiment in capturing anti-patterns that aren't currently (and may never be) native to Biome, with the goal being to provide opinionated machine-readable feedback that helps both humans and AI coding tools avoid common anti-patterns automatically.
Setup
Install package:
# NPM
npm install --save-dev @ryanmab/biome-plugin-avoidable-anti-patterns
# Bun
bun add --dev @ryanmab/biome-plugin-avoidable-anti-patterns
# Yarn
yarn add --dev @ryanmab/biome-plugin-avoidable-anti-patternsAdd the following rules to Biome's configuration file (biome.json):
{
"plugins": [
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectInitialiseState.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectDeriveState.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectChain.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectFetchWithoutCleanup.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectPassStateToParent.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectEventHandler.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectResetState.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectSetStateOnPropChange.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectSyncExternalStore.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectEmpty.grit",
"./node_modules/@ryanmab/biome-plugin-avoidable-anti-patterns/src/react-effect/noEffectManageParent.grit"
]
}Rules
[!WARNING] The ways in which anti-patterns can develop are practically endless, and this plugin is by no means exhaustive.
If you spot any false positives or negatives, have suggestions for additional rules, or have other feedback, please open an issue or pull request!
React
noEffectInitialiseState
Disallow using an Effect to initialise state.
import { useEffect, useState } from "react";
function Component() {
const [state, setState] = useState<string>();
useEffect(() => {
// 🔴 Avoid: initialising state in an Effect. Instead, initialise with "Hello World". For
// SSR hydration, prefer "useSyncExternalStore".
setState("Hello World");
}, []);
}Source: Avoiding Hydration Mismatches with useSyncExternalStore
noEffectDeriveState
Disallow using an Effect to derive new state based on existing state or props.
import { useEffect, useState } from "react";
function Form() {
const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
}Source: You Might Not Need an Effect
noEffectChain
Disallow using chains of Effects to trigger each other.
import { useEffect, useState } from "react";
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
useEffect(() => {
if (card !== null) {
setGoldCardCount((c) => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound((r) => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert("Good game!");
}, [isGameOver]);
}Source: You Might Not Need an Effect
noEffectFetchWithoutCleanup
Disallow using an Effect to trigger a network request without an accompanying cleanup function on unmount.
import { useEffect, useState } from "react";
function SearchResults(query: string) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Avoid: Fetching without cleanup logic
fetch(query, {}).then(async (json) => {
setResults(await json.json());
});
}, [query, page]);
}Source: You Might Not Need an Effect
noEffectPassStateToParent
Disallow using an Effect to pass internal state to the parent component.
import { useEffect, useState } from "react";
const isCloserToRightEdge = (e: unknown) => true;
function Toggle(onChange: (isOn: boolean) => void) {
const [isOn, setIsOn] = useState(false);
// 🔴 Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e: unknown) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
}Source: You Might Not Need an Effect
noEffectEventHandler
Disallow using an Effect for event-specific logic.
import { useEffect } from "react";
const showNotification = (message: string) => {};
const navigateTo = (path: string) => {};
type Product = {
name: string;
isInCart: boolean;
};
function ProductPage({
product,
addToCart,
}: {
product: Product;
addToCart: (product: Product) => void;
}) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo("/checkout");
}
}Source: You Might Not Need an Effect
noEffectResetState
Disallow using an Effect solely to reset state to its initial value.
import { useEffect, useState } from "react";
function ProfilePage(userId: number) {
const [comment, setComment] = useState("");
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment("");
}, [userId]);
}Source: You Might Not Need an Effect
noEffectSetStateOnPropChange
Disallow using an Effect to set state based solely on props.
import { useEffect, useState } from "react";
function List(items: unknown) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
}Source: You Might Not Need an Effect
noEffectSyncExternalStore
Disallow using an Effect to synchronise state with an external store.
import { useEffect, useState } from "react";
function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener("online", updateState);
window.addEventListener("offline", updateState);
return () => {
window.removeEventListener("online", updateState);
window.removeEventListener("offline", updateState);
};
}, []);
return isOnline;
}Source: You Might Not Need an Effect
noEffectEmpty
Disallow using an Effect which is empty.
import { useEffect } from "react";
function Game() {
const count = 0;
// 🔴 Avoid: Effects with no body
useEffect(() => {
// Empty!
}, []);
useEffect(() => {
// Empty!
}, [count]);
}noEffectManageParent
Disallow using an Effect which only depends on props.
import { useEffect } from "react";
function Modal(
arg: boolean,
{ isOpen, close }: { isOpen: boolean; close: () => void },
) {
useEffect(() => {
// do something
}, [arg, isOpen, close]);
return <></>;
}Limitations
Derived State and Properties
Due to limitations in GritQL, the query language used by Biome plugins, rules are not able to recursively pattern-match, and therefore are unable to follow variable references or chains of callbacks. This means rules fall short of identifying anti-patterns in highly dynamic or obfuscated code.
Most rules attempt to identify anti-patterns one level of derivation deep - though this is not always guaranteed:
import { useEffect, useState } from "react";
function Form() {
const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const [fullName, setFullName] = useState("");
useEffect(() => {
// Caught by `noEffectDeriveState`
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
useEffect(() => {
// Caught by `noEffectDeriveState`
const newFullName = firstName + " " + lastName;
setFullName(newFullName);
}, [firstName, lastName]);
useEffect(() => {
// NOT caught by `noEffectDeriveState`
const newFullName = firstName + " " + lastName;
const extraNewFullName = newFullName;
setFullName(extraNewFullName);
}, [firstName, lastName]);
}Acknowledgements
- React: You Might Not Need an Effect for inspiration around common React Effect anti-patterns.
- eslint-plugin-react-you-might-not-need-an-effect for inspiration on rule patterns, edge cases and tests.
- TkDodo: Avoiding Hydration Mismatches with useSyncExternalStore for inspiration on external store anti-patterns.
