Clean Code React: Refactor your utils.js/ts!
"Hey, I take pride in MY organized mess!" (source)
Problem
Your utils.ts
has become 200+ lines long 😨🤦♂️
Triage
“Wait, how did we end up here?”
- As we add more functionality to our React components, our files get bigger (of course they do)
- A standard strategy to combat growing file sizes is to move non-JSX logic into a companion
utils.ts
that sits in the same folder as the initial React component. - Over a couple of sprints, this
utils.ts
gets stuffed with more and more functions until we get an (extremely) bloatedutils.ts
. Foie gras, anyone? 🦆
“Surely it’s not that bad, right? 💁♀️”
"This is fine"
Illustration by KC Green
No, it’s bad - Really bad stuff. Here’s why:
- Increased entropy: Chance of cross-context function calls (and we don’t have
import
/require
syntax to rely on as hints) - Increased fatigue: Extremely fatiguing for developers to reason about multiple contexts, even if code does not cross contexts
- Increased risk of errors: High risk of human error due to high volume of logic and multiple contexts within same file
- Poor discoverability: Context information is lost under generic
utils.ts
filename - Large test suite: Tests for all contexts within
utils.ts
would usually sit underutils.test.ts
(unless you’ve already taken the effort to break it down into__test__/context_xyz.test.ts
) - Poor cohesion: Low overall cohesion (even if code is grouped context-wise), since a single file contains code which encompasses multiple contexts
Solution
Steps:
- Refactor
utils.ts
into smaller files within/utils
folder - Use meaningful names for each file to indicate context
- Colocate associated helper functions to specific contexts
- Export utils as a group via
utils/index.ts
Example code: refactor-your-utils
Before:
utils.ts
import { MyItem, MyItemPriceCurrency } from "../common/typedefs";
// item price utils
const sortItemsByPriceAmountAscending = (items: MyItem[]): MyItem[] =>
items.sort((a, b) => a.price.amount - b.price.amount);
export const getCheapestItem = (items: MyItem[]): MyItem => {
return sortItemsByPriceAmountAscending(items)[0];
};
export const getMostExpensiveItem = (items: MyItem[]): MyItem => {
return sortItemsByPriceAmountAscending(items).reverse()[0];
};
// item currency utils
export const filterBySgdItem = (items: MyItem[]) =>
items.filter(filterByItem("SGD"));
export const filterByJpyItem = (items: MyItem[]) =>
items.filter(filterByItem("JPY"));
const filterByItem = (targetCurrency: MyItemPriceCurrency) => (item: MyItem) =>
item.price.currency === targetCurrency;
// item component utils
export const makeItemComponentKey = (item: MyItem) => item.id;
utils.test.ts
import { MyItem } from "../common/typedefs";
import {
filterByJpyItem,
filterBySgdItem,
getCheapestItem,
getMostExpensiveItem,
makeItemComponentKey,
} from "./utils";
describe("utils", () => {
const appleItem: MyItem = {
id: "abcd",
title: "Apple",
price: { amount: 12.3, currency: "SGD" },
};
const pearItem: MyItem = {
id: "efgh",
title: "Pear",
price: { amount: 10.23, currency: "SGD" },
};
const cornItem: MyItem = {
id: "ijkl",
title: "Corn",
price: { amount: 30.01, currency: "JPY" },
};
const mockItems: MyItem[] = [appleItem, pearItem, cornItem];
it("gets cheapest item", () => {
expect(getCheapestItem(mockItems)).toEqual(pearItem);
});
it("gets most expensive item", () => {
expect(getMostExpensiveItem(mockItems)).toEqual(cornItem);
});
it("filters by sgd items", () => {
expect(filterBySgdItem(mockItems)).toEqual([appleItem, pearItem]);
});
it("filters by jpy items", () => {
expect(filterByJpyItem(mockItems)).toEqual([cornItem]);
});
it("makes item component key", () => {
expect(makeItemComponentKey(appleItem)).toEqual("abcd");
});
});
After:
utils/itemFilterUtils.ts
import { MyItem, MyItemPriceCurrency } from "../../common/typedefs";
export const filterBySgdItem = (items: MyItem[]) =>
items.filter(filterByItem("SGD"));
export const filterByJpyItem = (items: MyItem[]) =>
items.filter(filterByItem("JPY"));
const filterByItem = (targetCurrency: MyItemPriceCurrency) => (item: MyItem) =>
item.price.currency === targetCurrency;
utils/itemSortUtils.ts
import { MyItem } from "../../common/typedefs";
const sortItemsByPriceAmountAscending = (items: MyItem[]): MyItem[] =>
items.sort((a, b) => a.price.amount - b.price.amount);
export const getCheapestItem = (items: MyItem[]): MyItem => {
return sortItemsByPriceAmountAscending(items)[0];
};
export const getMostExpensiveItem = (items: MyItem[]): MyItem => {
return sortItemsByPriceAmountAscending(items).reverse()[0];
};
utils/itemRenderUtils.ts
import { MyItem } from "../../common/typedefs";
export const makeItemComponentKey = (item: MyItem) => item.id;
utils/index.ts
export * from "./itemFilterUtils";
export * from "./itemRenderUtils";
export * from "./itemSortUtils";
utils/__test__/itemFilterUtils.test.ts
import { filterByJpyItem, filterBySgdItem } from "../itemFilterUtils";
import { testMocks } from "./testMocks";
describe("itemFilterUtils", () => {
const { mockItems, appleItem, pearItem, cornItem } = testMocks;
it("filters by sgd items", () => {
expect(filterBySgdItem(mockItems)).toEqual([appleItem, pearItem]);
});
it("filters by jpy items", () => {
expect(filterByJpyItem(mockItems)).toEqual([cornItem]);
});
});
utils/__test__/itemSortUtils.test.ts
import { getCheapestItem, getMostExpensiveItem } from "../itemSortUtils";
import { testMocks } from "./testMocks";
describe("itemSortUtils", () => {
const { mockItems, pearItem, cornItem } = testMocks;
it("gets cheapest item", () => {
expect(getCheapestItem(mockItems)).toEqual(pearItem);
});
it("gets most expensive item", () => {
expect(getMostExpensiveItem(mockItems)).toEqual(cornItem);
});
});
utils/__test__/itemRenderUtils.test.ts
import { makeItemComponentKey } from "../itemRenderUtils";
import { testMocks } from "./testMocks";
describe("itemRenderUtils", () => {
const { appleItem } = testMocks;
it("makes item component key", () => {
expect(makeItemComponentKey(appleItem)).toEqual("abcd");
});
});
Summary
Here’s why you should refactor (read: kill) all your utils.ts
:
- Low entropy: Colocation of context-specific core and helper functions help to reduce cross-context function calls
- Less fatigue: Single-context files are far easier to reason about
- Less risk of errors: Narrower contexts reduces risk of human error
- Good discoverability: Well-named files help with overall discoverability of project
- Small test suites: Smaller test suites are easier to maintain
- Good cohesion: High file-level cohesion, since a single file contains only code that is specific to a single context
As always, keep your code ⭐️ sparkling clean! ⭐️ Peace out ✌️