Approximate reading time: 4-5 minutes. Full example code available as codesandbox and repository.
In the first part, we saw how to take a fundamental, but usually implicit aspect of a component — the “phases” it can go through — and make it explicit, both to fellow developers and to developer tools; and how the increase in clarity translates into maintainability and reason-ability.
In this part, we’ll take something that is explicit but usually scattered, consolidate it, put a name on it, and disentangle it from the UI, so as to think about it more clearly, gain the option of developing it separately, and replace it when the situation calls for that.
I’m talking about I/O.
Often, a call to fetch
or document.title
is enough to either trick the developer into testing a component within a slow and heavy environment (JSDOM, MSW, …), or skip the testing entirely, because there’s too much friction. This would be the case with the shape we left our MagicCodeLogin
component in from part 1, where I/O calls (highlighted) are expected to be provided by the environment via the global scope:
export const MagicCodeLogin: React.FC = () => {
useEffect(() => {
switch (state.phase) {
case "mounting": {
const persistedEmail = localStorage.get("email");
transition(/* ... */);
break;
}
case "success": {
localStorage.removeItem("email");
window.location.href = "/";
break;
}
/* ... */
}
}, [state.phase]);
const handleEmailSubmit = async (
email: string,
): Promise<void> => {
transition({ phase: "submitting-email", email });
const res = await apiClient.requestMagicCode(email);
if (res.ok) {
localStorage.setItem("email", email);
transition(/* ... */);
} else {
transition(/* ... */);
}
};
/* ... */
};
Here’s what I like to do instead (with persistence made async just for kicks):
interface MagicCodeLoginIo {
requestMagicCode(params: {
email: string;
}): Promise<{ ok: true } | { ok: false; error: string }>;
verifyMagicCode(params: {
code: string;
email: string;
}): Promise<{ ok: true } | { ok: false; error: string }>;
persistEmail(email: string): Promise<void>;
readPersistedEmail(): Promise<string | null>;
clearPersistedEmail(): Promise<void>;
redirectToHome(): Promise<void>;
}
export const MagicCodeLogin: React.FC<{
io: MagicCodeLoginIo;
}> = () => {
useEffect(() => {
switch (state.phase) {
case "mounting": {
io.readPersistedEmail().then((persistedEmail) => {
if (persistedEmail === null) {
transition(/* ... */);
} else {
transition(/* ... */);
}
});
break;
}
case "success": {
io.clearPersistedEmail().then(() => {
io.redirectToHome();
});
break;
}
}
}, [state.phase, io]);
const handleEmailSubmit = async (
email: string,
): Promise<void> => {
transition({ phase: "submitting-email", email });
const res = await io.requestMagicCode({ email });
if (res.ok) {
await io.persistEmail(email);
transition(/* ... */);
} else {
transition(/* ... */);
}
};
/* ... */
};
All I/O functions are grouped into an object which is received as a prop. This is the same strategy from Faster, simpler tests with explicit IO channels , including the fact that we’re ultimately just giving a friendlier (and, I’d argue, more useful for the scenario) name to “dependency injection”.
We’re also surfacing and announcing I/O requirements as part of the component’s public API, instead of hiding them in the implementation. The component can now state what it wants in terms of I/O and outsource how it should be done. Persist data remotely instead of local storage? Send verification info as JSON instead of form data? No need to modify the component. Test that the component is invoking the right I/O operations at the right time? Pass a mock I/O object and assert that the expected methods are called. Display the component in Storybook with stable data to create snapshots for visual testing? Pass a stub I/O object with hardcoded data.
In most cases I’d define the “standard” (production) implementation in module scope in the same file as the component, and provide it the default value for the io
prop:
export const MagicCodeLogin: React.FC<{
io?: MagicCodeLoginIo;
}> = ({ io = DEFAULT_IO }) => {
/* ... */
};
const DEFAULT_IO: MagicCodeLoginIo = {
async requestMagicCode({ email }) {
/* ... */
},
/* ... */
};
Other options include passing it down from the calling component either after getting it from context or constructing it (in the latter case, remember to ensure stability with useMemo
).
We’ve grouped I/O functions into a language-level structure (an object), put an explicit name on it, separated requirements from implementation, and allowed the implementation to be provided externally; this makes our component easier to reason about, faster to develop and test, and adaptable to different environments.