The Factory pattern — or why you order by name, not by recipe
At a chai stall you say two words — "one kadak" — and a finished cup comes back. You don't measure the leaves, you don't know the sugar ratio, and if the stall changes its recipe tomorrow, your order doesn't change. A factory is that counter, in code: one function you call by name that hands back a finished, ready-to-use object. Here's the version every frontend actually needs — a signup form whose fields keep drifting apart, and an API client that stops asking you for the same token twice.
The issue
At a chai stall you say two words — "one kadak" — and thirty seconds later a finished cup arrives. You never measured the leaves, never learned the sugar ratio, never touched the pan. And if the stall quietly changes its recipe tomorrow, your order stays exactly the same: two words. That counter is a factory. In code, it's a function you call by name that hands back a finished object, fully assembled, ready to use.
Here's where a frontend hits this first: forms. A signup form, a checkout form, a profile form. Every field in every one of them needs the same set of things — a type, an id, a label, whether it's required, and a rule that decides if what the user typed is acceptable. Same five things, over and over, in every file that renders a form.
The naive version
The obvious move is to just write the object out wherever you need it. It's five lines. Why would you make a whole function for that?
// signup.jsx
const emailField = {
type: "email",
id: "email",
label: "Email address",
required: true,
validate: (v) => /.+@.+/.test(v),
};
// checkout.jsx — the same field, written out again
const billingEmail = {
type: "email",
id: "billing-email",
label: "Billing email",
required: true,
validate: (v) => /.+@.+/.test(v),
};
// profile.jsx — written out again, on a Friday
const contactEmail = {
type: "email",
id: "contact-email",
label: "Contact email",
required: true,
// validate: ...forgotten
};That third field is a real bug, and it will be reported like this: "profile lets me save asdf as my email." Nobody will connect it to a missing line in a form config. Someone will spend an afternoon on it.
Fix: one function that knows how to build them
Move the knowledge into one place. A small table of makers — one per field type — and a single function everyone calls. The rule for what makes an email valid now lives in exactly one line, and every email field in the product runs through it:
const nonEmpty = (v) => String(v).trim() !== "";
const isEmail = (v) => /.+@.+/.test(v);
const makers = {
text: (p) => ({ ...p, type: "text", validate: nonEmpty }),
email: (p) => ({ ...p, type: "email", validate: isEmail }),
number: (p) => ({ ...p, type: "number", validate: Number.isFinite }),
checkbox: (p) => ({ ...p, type: "checkbox", validate: () => true }),
};
export function createField({ type, ...rest }) {
const make = makers[type];
if (!make) throw new Error(`Unknown field type: ${type}`);
return { required: false, ...make(rest) };
}The form code gets shorter and, more usefully, gets boring. It describes what the form is and says nothing about how a field works:
const fields = [
createField({ type: "email", id: "email", label: "Email address", required: true }),
createField({ type: "text", id: "name", label: "Full name", required: true }),
createField({ type: "checkbox", id: "terms", label: "I agree to the terms" }),
];
return fields.map((f) => <Input key={f.id} {...f} />);Notice what just became possible for the business, not just the codebase: that list of three objects is plain data. It can come from a config file, from the backend, from a dashboard someone in ops edits. "Add a GST number field for Indian customers" stops being a frontend ticket and becomes a row in a table — because the code no longer has an opinion about which fields exist, only about how each type behaves.
The other half: setup that only happens once
The second thing factories are for is remembering configuration so you stop passing it around. Every app has an API base URL and an auth token, and without a factory those two values end up threaded through every function that ever makes a request. Build the client once instead, and let it hold on to them:
export function createApiClient({ baseUrl, token, fetch = window.fetch }) {
const request = async (method, path, body) => {
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: body && JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.status === 204 ? null : res.json();
};
return {
get: (p) => request("GET", p),
post: (p, b) => request("POST", p, b),
del: (p) => request("DELETE", p),
};
}Three methods, all sharing one private request function, which in turn is holding on to the baseUrl and token that were handed in at the start. Nothing outside can see those values — this is the same trick Issue 016 uses to keep a token private, except here you can make as many of these as you want.
And that last part is where it pays off. Look at the fetch parameter — it defaults to the real one, but you can hand in your own. Which means testing the checkout flow doesn't need a network, a server, or a mocking library:
// app.js — configured once, at startup
export const api = createApiClient({
baseUrl: "https://api.acme.com",
token: session.token,
});
await api.get("/orders"); // base url prefixed, auth header attached
// checkout.test.js — same code, a different world
const api = createApiClient({
baseUrl: "http://localhost",
token: "test-token",
fetch: fakeFetch, // no network; returns canned replies
});Same application code, both times. It calls api.get("/orders") and never finds out whether that hit a real server, a staging one, or a hand-written reply sitting in a test file. That's the whole point of "depend on the shape, not the type" — and it's why factories make the flaky, slow parts of a test suite go away.
When not to reach for one
A factory is indirection, and indirection is only worth paying for when something varies. If there is exactly one way to build the thing and there always will be, writing createThing() around new Thing() hasn't abstracted anything — it's renamed it.
For the typed version of all this — discriminated unions so createField({ type: "number" }) knows it has min and max, and the DI-container end of the spectrum — patterns.dev's Factory Pattern writeup is the right next stop.