Engineering
Tero Laitinen
Mar 31, 2023
Injecting Hooks Into React Components
The pace of development of our consumer web app, wolt.com, is accelerating as the number of contributors grows. As if dictated by a law of nature, the overall complexity grows despite our significant refactoring efforts. There are many sources of complexity. First of all, we strive to provide a smooth and delightful experience to all users of modern mobile and desktop browsers. We do our best to help search engines to index our offerings. And finally, we operate in 23 countries, each with specific regulatory requirements and other needs. All this makes the specification for the application intrinsically complex.
In a rapidly changing operating environment, the requirements for our application are often iteratively and partially specified. The number of parallel projects compounds the challenge. It is increasingly difficult for an individual contributor to keep track of what other teams are working on and anticipate possible source code conflicts. Understanding and keeping existing features working while modernizing code and adding new functionality relies heavily on quality code reviews from experienced individuals but more and more also on an ever-growing number of regression tests.
Modular code is easy to review and test, and it is also harder to break accidentally. Dependency injection, providing dependencies at a call site rather than directly importing them from a function or class itself, is a cornerstone of loosely-coupled codebases. Injecting dependencies consisting of stateless functions, components, and primitive data types into React components through props or a context does not cause problems. However, injecting hooks requires special consideration.
While refreshing the look and feel of our authentication flow, we created a fresh set of self-contained React components into which we could inject stateful authentication hooks. As a result, writing Storybook stories for the components was straightforward as we could supply interactive mock implementations for authentication flows. Developing a comprehensive jest unit test suite did not require any type-unsafe module mocking, and we could conveniently cover all authentication control flows with non-interactive mock implementations.
Table of Contents
Loosely-Coupled Code With Dependency Injection
Injecting Hook Dependencies
Passing Hooks in Props
Currying Hook Parameters
Passing Hooks Through Context
Authentication Modal in Wolt.com
Non-Interactive Auth Hook Mocks for Tests
Interactive Auth Hook Mocks for Storybook
Conclusions
Loosely-Coupled Code With Dependency Injection
Dependency injection adds some cognitive overhead and moderately increases upfront development effort. However, maintenance and further development work may often dwarf the initial implementation effort. Designing minimal interfaces for React components and including self-contained type definitions may feel like busywork leading to much boilerplate. Still, these modular surfaces can pay hefty dividends as the codebase grows. Granted, it is possible and even enticing to overengineer. In practice, dependency injection makes sense only if it brings tangible benefits for testing or component reusability.
Testing complex components using React Testing Library may require mocking code modules, verbose data mocks, and multiple React Context wrappers. Code module mocks are type-unsafe and can be sensitive to the order of imports. Data mocks can be untyped, partial, and drift out of sync with backend services. React Context wrappers may increase the complexity and verbosity of tests.
If such a complex React component is an imported dependency of another component under testing, injecting the complex component may greatly simplify the tests. In the example below, the component SimpleParent
imports the component ComplexChild
, which we assume here to be cumbersome to test.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import React, { useState } from 'react'; import { ComplexChild } from '../components/ComplexChild'; import { Button } from '../components/Button'; interface Props { onSomethingHappens: () => void; } export const SimpleParent = ({ onSomethingHappens }: Props) => { const [isVisible, setIsVisible] = useState(false); return ( <div> {isVisible && <ComplexChild onSomethingHappens={onSomethingHappens} />} <Button onClick={() => setIsVisible((visible) => !visible)}>Click me</Button> </div> ); };
The component SimpleParent
's functionality is straightforward to test, but getting it to mount with ComplexChild
may require elaborate orchestration. The following test code may throw an exception if ComplexChild
's invariants are unsatisfied.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SimpleParent } from './SimpleParent'; describe('SimpleParent', () => { it('does not render ComplexChild initially', () => { render(<SimpleParent onSomethingHappens={() => {}} />); expect(screen.queryByText('Complex child')).not.toBeInTheDocument(); }); it('renders ComplexChild after clicking the button', async () => { const user = userEvent.setup(); render(<SimpleParent onSomethingHappens={() => {}} />); await user.click(screen.getByText('Click me')); expect(screen.getByText('Complex child')).toBeInTheDocument(); }); it('hides ComplexChild after clicking the button again', async () => { const user = userEvent.setup(); render(<SimpleParent onSomethingHappens={() => {}} />); await user.click(screen.getByText('Click me')); await user.click(screen.getByText('Click me')); expect(screen.queryByText('Complex child')).not.toBeInTheDocument(); }); });
Even after arranging suitable conditions to mount ComplexChild
, it can still be challenging to trigger it to call the callback passed in the prop onSomethingHappens
. Module mocking is often possible, but the test suite must provide mock implementations for all the code from the mocked module - even if some code works without mocking. Imports from barrel files can further complicate module mocking. Also, module mocks are type-unsafe. Type safety in tests may not feel as important as in production code, but it can help keep test maintenance costs at bay. Finally, module mocking may be tedious to arrange in a component explorer.
As other development pressures build up, this testing difficulty can prod the developer to move on and leave a part of SimpleParent
's contract untested. An alternative implementation for SimpleParent
below receives the dependency for ComplexChild
through props instead of importing it directly.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
interface Props { ComplexChild: React.ComponentType<{ onSomethingHappens: () => void }>; onSomethingHappens: () => void; } export const SimpleParent = ({ ComplexChild, onSomethingHappens }: Props) => { const [isVisible, setIsVisible] = useState(false); return ( <div> {isVisible && <ComplexChild onSomethingHappens={onSomethingHappens} />} <Button onClick={() => setIsVisible((visible) => !visible)}>Click me</Button> </div> ); };
With this implementation, there are no longer any hurdles with testing the component. The test code can pass a suitable implementation for ComplexChild
that is easy to mount and triggers the callback onSomethingHappens
when desired.
A render function, e.g., renderComplexChild: (props:{onSomethingHappens: () => void}) => JSX.Element
, would work here equally well for dependency injection, but it does not create a separate node in the React component tree.
It is often convenient and informative to reuse the typings of a dependency for which it is possible to supply an alternative implementation. If a component dependency requires many props, it can be tedious to duplicate their type signatures. Also, the component can use the actual implementation of a component dependency as the default value, simplifying call sites. The downside of this approach is that it creates a dependency to the imported component ComplexChild
and makes the component SimpleParent
less self-contained. However, this can often be an acceptable tradeoff.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
interface Props { ComplexChild: React.ComponentType<{ onSomethingHappens: () => void }>; onSomethingHappens: () => void; } export const SimpleParent = ({ ComplexChild, onSomethingHappens }: Props) => { const [isVisible, setIsVisible] = useState(false); return ( <div> {isVisible && <ComplexChild onSomethingHappens={onSomethingHappens} />} <Button onClick={() => setIsVisible((visible) => !visible)}>Click me</Button> </div> ); };
The revised test code below introduces a convenience component we typically name Component
(being tested). It passes default values for all props while allowing the call site to override any subset of them. Such a testing component improves test readability when explicitly assigning only the relevant props.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SimpleParent } from './SimpleParent'; type SimpleParentProps = React.ComponentProps<typeof SimpleParent>; const DefaultComplexChild = () => { return <div>Complex child</div>; }; const Component: React.FC<Partial<SimpleParentProps>> = (props) => { return ( <SimpleParent ComplexChild={DefaultComplexChild} onSomethingHappens={() => {}} {...props} /> ); }; describe('SimpleParent', () => { it('does not render ComplexChild initially', () => { render(<Component />); expect(screen.queryByText('Complex child')).not.toBeInTheDocument(); }); it('renders ComplexChild after clicking the button', async () => { const user = userEvent.setup(); render(<Component />); await user.click(screen.getByText('Click me')); expect(screen.getByText('Complex child')).toBeInTheDocument(); }); it('hides ComplexChild after clicking the button again', async () => { const user = userEvent.setup(); render(<Component />); await user.click(screen.getByText('Click me')); await user.click(screen.getByText('Click me')); expect(screen.queryByText('Complex child')).not.toBeInTheDocument(); }); it('calls onSomethingHappens when something happens in the child', async () => { const ComplexChild: SimpleParentProps['ComplexChild'] = ({ onSomethingHappens }) => { return ( <div> <div>Complex child</div> <button onClick={onSomethingHappens}>Complex child button</button> </div> ); }; const onSomethingHappens = jest.fn(); const user = userEvent.setup(); render(<Component ComplexChild={ComplexChild} onSomethingHappens={onSomethingHappens} />); await user.click(screen.getByText('Click me')); await user.click(screen.getByText('Complex child button')); expect(onSomethingHappens).toHaveBeenCalledOnce(); }); });
The first three tests in the suite use the default mock implementation for ComplexChild
, which does not call onSomethingHappens
. To test the invariant that SimpleParent
calls the callback onSomethingHappens
when ComplexChild
calls its callback, the last test provides an alternative implementation for ComplexChild
, which registers the prop onSomethingHappens
as a click event handler to a button
element. It suffices to click the button in the test code to trigger the callback.
Dependency injection may simplify mounting components in component explorers like Storybook. Arranging appropriate conditions for the production implementation of ComplexChild
can be challenging in a Storybook story. Mocking modules, while possible, seems more cumbersome than with a test runner like Jest.
Making it possible to supply a suitable implementation for a dependency at a call site allows for better code reusability. In addition to providing alternative implementations in test and component explorer code, production code can similarly pass multiple compatible implementations for dependencies in different parts of the code base.
Injecting Hook Dependencies
Hooks allow writing stateful React components on top of seemingly pure functions. React runtime returns different results from hook calls when the component re-renders and the component function runs again. A component must call the same hooks in the same order each time it renders. If the code violates this principle, React runtime may throw one of the following errors:
Warning: React has detected a change in the order of Hooks called by MyComponent. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks:
Uncaught Error: Rendered more hooks than during the previous render.
Uncaught Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
When injecting hook dependencies, it is easier to break the rules of hooks accidentally. Suppose a parent (or a grandparent) component injects a hook dependency into a child (or a grandchild) component and changes which built-in hooks the injected hook function calls during the child component's lifetime. In that case, the application may behave unexpectedly or even crash. However, the custom hook function does not need a stable referential identity as long as it calls the same built-in hooks, and we'll revisit this later when binding parameters to hooks.
There are at least three ways to inject hook dependencies into React components:
passing in props,
binding parameters in a function closure,
and retrieving them from a React context.
Passing Hooks in Props
Instead of importing a hook, it is possible to pass it in props. However, we must be mindful of the rules of hooks and not change the hook call order. Also, passing hooks in props may be considered unidiomatic by some. If these are not deal breakers, it can be a viable way to inject hook dependencies into components.
The component Child
below, without dependency injection, imports the custom hook useFoo
:
1 2 3 4 5 6 7
import React from "react"; import { useFoo } from "hooks/useFoo"; export const Child = () => { const foo = useFoo(); return <div>Foo: {foo}</div>; };
Instead of importing useFoo
directly, the revised code below provides it to the component in props. As a nice bonus, the component no longer bakes in any hidden side-effects or dependencies to the global state and communicates its intent clearer through its type signature.
1 2 3 4 5 6 7 8 9 10
import React from 'react'; interface Props { useFoo: () => string; } export const Child = ({ useFoo }: Props) => { const foo = useFoo(); return <div>Foo: {foo}</div>; };
The call site, for example, the component Parent
below, can supply a suitable implementation for the hook useFoo
.
1 2 3 4 5 6 7 8 9 10 11 12
import React from 'react'; import { Child } from 'components/Child'; import { useFoo } from 'hooks/useFoo'; export const Parent = () => { return ( <div> <Child useFoo={useFoo} /> </div> ); };
Like before, if a hook dependency has a verbose type signature, the component may reuse the type and, when convenience prompts, supply the imported implementation as the default value. And similarly as with component dependencies, the possibly acceptable tradeoff is that the type signature of the hook creates a dependency to the imported hook useFoo
and makes the component Child
less self-contained.
1 2 3 4 5 6 7 8 9 10 11
import React from "react"; import { useFoo as defaultUseFoo } from "hooks/useFoo"; interface Props { useFoo?: typeof defaultUseFoo; } export const Child = ({ useFoo = defaultUseFoo }: Props) => { const foo = useFoo(); return <div>Foo: {foo}</div>; };
Supplying the default implementation for the hook useFoo
as the default value simplifies the call site when there is no need for dependency injection.
1 2 3 4 5 6 7 8 9 10 11
import React from "react"; import { Child } from "components/Child"; export const Parent = () => { return ( <div> <Child /> </div> ); };
A test can provide a different implementation for the hook useFoo
without resorting to type-unsafe module mocking.
1 2 3 4 5 6 7 8 9 10 11 12
import React from "react"; import { render, screen } from "@testing-library/react"; import Child from "components/Child"; // it does not use built-in hooks but matches the type signature const useFoo = () => "foo"; test("renders foo", () => { render(<Child useFoo={useFoo} />); const divElement = screen.getByText(/Foo: foo/); expect(divElement).toBeInTheDocument(); });
A Storybook story can provide yet another implementation that can prompt input from a user and wait before state changes to simulate loading delays. We’ll revisit this later with our revised authentication modal.
In principle, it is possible to provide an implementation for a hook within the parent component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
import React from "react"; import { Child } from "components/Child"; interface Props { useBar: () => string; useBaz: () => string; } export const Parent = ({ useBar, useBaz }: Props) => { // inline definition for useFoo const useFoo = () => { const bar = useBar(); const baz = useBaz(); return `${bar} ${baz}`; }; return ( <div> <Child useFoo={useFoo} /> </div> ); };
Supplying an inline definition for a hook passed a prop does not break the rules of hooks but may cause Child
to re-render unnecessarily unless we wrap useFoo
in useCallback
. However, ESLint justifiably complains about this:
React Hook "
useBar"
cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function.
It is possible to circumvent the issue by extracting the inlined hook to a hook-creating function and then memoizing the result.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
import React, { useMemo } from "react"; import { Child } from "./components/Child"; interface Props { useBar: () => string; useBaz: () => string; } // hook creator/factory const createUseFoo = ({ useBar, useBaz }: Pick<Props, "useBar" | "useBaz">) => () => { const bar = useBar(); const baz = useBaz(); return `${bar} ${baz}`; };
Such hook factories can help bind a parameter in a hook before passing it to a child component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
import React, { useMemo } from "react"; import { Child } from "./components/Child"; interface Props { useBar: () => string; useFooWithParam: (param: string) => string; } const createUseFoo = ({ useBar, useFooWithParam }: Pick<Props, "useBar" | "useFooWithParam">) => () => { const bar = useBar(); // binding the parameter of `useFooWithParam` const foo = useFooWithParam(bar); return foo; }; export const Parent = ({ useBar, useFooWithParam }: Props) => { const useFoo = useMemo( () => createUseFoo({ useBar, useFooWithParam }), [useBar, useFooWithParam] ); return ( <div> <Child useFoo={useFoo} /> </div> ); };
However, with hook factories, ESLint no longer protects us from breaking the rules of hooks, e.g., calling a hook conditionally. The following code calls useFooWithParam
conditionally.
1 2 3 4 5 6 7
const createUseFoo = ({ useBar, useFooWithParam }: Pick<Props, "useBar" | "useFooWithParam">) => () => { const bar = useBar(); // Calling useFooWithParam conditionally changes the hook call order return bar ? useFooWithParam(bar) : ""; };
Currying Hook Parameters
If passing hooks in props feels unidiomatic or like a footgun primed to break the rules of hooks we can consider currying hook parameters so that the type signature of a function becomes:
1
type ComponentFactory = (hooks: Hooks) => (props: Props) => JSX.Element;
By binding hook parameters in the closure of the outer function, they can no longer change during the component's lifetime. It also serves as a reminder to contributors that they ought to treat them differently from the rest of the props. If curly braces are unnecessary for the outer function, the code does not look much worse than passing hooks in props.
1 2 3 4 5 6 7 8 9 10
import React from "react"; interface Hooks { useFoo: () => string; } export const createChild = ({ useFoo }: Hooks) => () => { const foo = useFoo(); return <div>Foo: {foo}</div>; };
Often hooks need to be drilled through just as props, making call sites more verbose. We may leverage structural subtyping while drilling hooks through components if type signatures are compatible and just pass the parameter hooks
onwards either unmodified or overriding a subset of its properties.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
import React from "react"; import { createChild } from "components/Child"; type ChildHooks = Parameters<typeof createChild>[0]; interface ParentHooks extends ChildHooks { useBar: () => string; } export const createParent = (hooks: ParentHooks) => { const { useBar } = hooks; const Child = createChild(hooks); return () => { const bar = useBar(); return ( <div> Bar: {bar} <Child /> </div> ); }; };
Component factories remain a cosmetic reminder of the intended usage, though, as nothing prevents from creating a child component in the render function, and this is indeed what would be required should we want to bind a parameter to a hook before passing it to a child component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
import React, { useMemo } from "react"; import { createChild } from "components/Child"; interface Hooks { useFooWithParam: (param: string) => string; } interface Props { param: string; } const createUseFoo = ({ param, useFooWithParam, }: Pick<Hooks, "useFooWithParam"> & Pick<Props, "param">) => () => { const foo = useFooWithParam(param); return foo; }; export const createParent = ({ useBar, useFooWithParam }: Hooks) => { return ({ param }: Props) => { const useFoo = useMemo(() => createUseFoo({ useFooWithParam }, [useFooWithParam, param]); const Child = useMemo(() => createChild({ useFoo }), [useFoo]); return <Child />; }; };
The code above may prompt some well-justified comments in a code review. If you want to bind parameters to hooks, it may be more straightforward to pass hooks in props instead of currying hook parameters.
Passing Hooks Through Context
If hook drilling feels too laborious, you can provide hooks through a Context. A small library, react-facade helps with that by using Proxy magic for increased readability and convenience.
You must accompany your React component with a separate "facade" TypeScript module created with placeholder hooks when using react-facade.
1 2 3 4 5 6 7
import { createFacade } from "react-facade"; type Hooks = { useFoo: () => string; }; export const [hooks, ImplementationProvider] = createFacade<Hooks>();
The component imports the hooks proxy from the facade and calls hooks. This approach has the least boilerplate so far and only requires prefixing hook calls with hooks.
.
1 2 3 4 5 6 7
import React from "react"; import { hooks } from "./facade"; export const Child = () => { const foo = hooks.usefoo(); return <div>Foo: {foo}</div>; };
You must provide hook implementations for the facade using ImplementationProvider
. Multiple components can share a facade or even the whole app, but sharing a facade negates some of the benefits of specifying types for hooks in components. It also pulls all the hook implementations of the facade in the same code bundle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import React from "react"; import { Routes } from "containers/Routes"; import { useFoo } from "hooks/useFoo"; // defined in the outer scope to avoid rerenders const hooks = { useFoo, }; export const App = () => { return ( <ImplementationProvider implementation={hooks}> <Routes /> </ImplementationProvider> ); };
The most bundle-size friendly and modular way to inject hooks through a Context is with component-specific facades. This extreme, however, introduces the most boilerplate in call sites. Creating route- and bundle-specific facades would not affect bundle size. It might be an acceptable middle ground, but depending on the number of the components sharing the facade might still couple too many components together.
Authentication Modal in Wolt.com
Our web application at wolt.com allows website users to browse venues and even to participate in group orders as a guest without creating a user account, but signing in is required to access all features. We’ve built the authentication flow in a largely self-contained modal component. After acquiring an authentication token from one of the authentication providers, the authentication modal may ask to fill in the details for a new user account, enter a verification code sent as a text message, or prompt if the user wishes to link the provided authentication credentials with a matching existing user account. Email or phone number verification may be necessary to complete account linking. After completing the flow, the user is logged in and ready to use the application.
The self-contained authentication modal component AuthModal
uses six screens, each implemented in a separate component. The component MethodSelect
allows the user to choose the authentication provider to log in or to create an account. In case the user continues using the email authentication provider, the modal transitions to the component EmailSent
, which shows a confirmation message and offers the possibility to resend the email. Once the user acquires an authentication token, the authentication flow continues either with the component CreateAccount
, to fill in the user account details, or with LinkAccount
, to confirm linking the authentication credentials with a matching account. Account creation flow continues with phone number verification with the component VerifyCode
, from which the user may transition to CodeNotReceived
, where the user may request another code or contact support. The component AuthModalContainer
hooks AuthModal
into the rest of the application by providing the initial modal state and a number of callback functions that communicate with backend services and tie the modal to the rest of the application.
Each of these components has a Jest test suite and a Storybook story. The authentication flow is in the application’s critical path, so it must function correctly under all circumstances. We’ve covered simple invariants like asserting that the component renders an appropriate element and correctly registers event handlers, and more complex ones covering parts of the authentication flow checking that the behavior is correct both in successful and unsuccessful cases.
The component AuthModal
uses three hooks that cannot be easily used in Jest tests and Storybook stories: useAppleLogin
, useFacebookLogin
, and useHCaptcha
, used to implement Apple login flow, Facebook login flow, and run a hCaptcha challenge, respectively.
The hook useAppleLogin
has the following type signature:
1 2 3 4
useAppleLogin: () => { login: () => Promise<LoginWithAppleResult>; isLoading: boolean; }
The hook useFacebookLogin
has an identical type signature, except it uses LoginWithFacebookResult
instead of LoginWithAppleResult
. These result objects convey information about a successful authentication event and whether the user needs to create or link an account. The property isLoading
indicates whether the respective JavaScript SDK is loading.
The hook useHCaptcha
has the following type signature:
1 2 3 4
useHcaptcha: (id: string, sitekey?: string) => { hcaptchaStatus: 'loading' | 'idle' | 'busy' | 'success' | 'error' | 'expired'; runHCaptcha: () => Promise<string>; }
The AuthModal
component curries its hook parameters and provides default implementations. Passing the hook parameters in props would have been equally acceptable, but we aligned on reminding contributors that these parameters should not be changed during the component's lifetime.
1 2 3 4 5 6 7 8 9 10 11
export const createAuthModal = ({ useAppleLogin = useAppleLoginImpl, useFacebookLogin = useFacebookLoginImpl, useHcaptcha = useHcaptchaImpl, }: { useAppleLogin?: typeof useAppleLoginImpl; useFacebookLogin?: typeof useFacebookLoginImpl; useHcaptcha?: typeof useHcaptchaImpl; }) => { // ... };
Non-Interactive Auth Hook Mocks for Tests
The default implementation for useAppleLogin
uses Apple SDK and calls window.AppleID.auth.signIn()
to initiate the Apple sign-in flow. After that, it attempts to acquire a Wolt authentication token from our authentication service using the Apple authentication credentials. The Wolt authentication service may respond that a matching user account could be linked to the authentication credentials. To facilitate testing, we introduced a function createUseMockAppleLogin
, which returns a mock hook that returns predefined parameters.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
const createUseMockAppleLogin = ( result: Awaited<ReturnType<ReturnType<typeof useAppleLogin>['login']>> = Success.of({ status: 'success', }), isLoading = false, ) => { const useMockAppleLogin = () => { const login = useCallback(() => Promise.resolve(result), []); return { login, isLoading, }; }; return useMockAppleLogin; };
We could then instruct the mock Apple login hook to provide suitable return values for each relevant test case. For example, in the test case below, we assert that the first and last names are prefilled when the Apple login succeeds, but there is no matching user account.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
it('apple login without account goes to "Create account" and prefills name', async () => { const Component = createComponent({ useAppleLogin: createUseMockAppleLogin( Success.of({ status: 'signupRequired', values: { appleAuthCode: 'apple auth code', appleIdToken: 'apple id token', email: 'test@example.com', firstName: 'Apple first', lastName: 'Apple last', }, }), false, ), }); const { user } = render(<Component />); await user.click(screen.getByTestId(methodSelectTestSelectors.apple)); expect(screen.getByTestId(createAccountTestSelectors.firstName)).toHaveValue('Apple first'); expect(screen.getByTestId(createAccountTestSelectors.lastName)).toHaveValue('Apple last'); });
The default implementation for useHcaptcha
uses hCaptcha SDK and calls hcaptcha.render
and hcaptcha.execute
to run an hCaptcha challenge. Like before, we used a mock implementation, createUseMockHcaptcha
, to test control flows affected by what the hook useHcaptcha
returns.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
const createUseMockHcaptcha = ( initialStatus: ReturnType<typeof useHcaptcha>['hcaptchaStatus'], success: boolean, ) => { const useHcaptcha = () => { const [status, setStatus] = useState<typeof initialStatus>(initialStatus); const runHcaptcha = async () => { setStatus('busy'); if (success) { setStatus('success'); return 'success'; } else { setStatus('error'); } }; return { hcaptchaStatus: status, runHcaptcha }; }; return useHcaptcha; };
As with useMockAppleLogin
, we could instruct the mock hCaptcha hook to return appropriate result to test all the relevant corner cases. For example, the test below asserts that the component MethodSelect
shows the correct error message when a solved hCaptcha challenge has expired:
1 2 3 4 5 6 7 8 9
it('shows recaptcha expired error in method select screen', async () => { const Component = createComponent({ useHcaptcha: createUseMockHcaptcha('expired', false), }); render(<Component />); expect(screen.getByTestId(methodSelectTestSelectors.emailLoginError)).toHaveTextContent( getDefaultTranslation('auth.method-select.hcaptcha-expired'), ); });
The test suite for AuthModal
does not use any module mocking, and all expressions are properly typed. If the hooks’ type signatures change, TypeScript will pinpoint which tests need to be updated.
Interactive Auth Hook Mocks for Storybook
The Storybook story below for AuthModal
supplies an interactive fake implementation for the Apple login hook. It uses window.confirm
to prompt information from the user and setTimeout
to simulate loading delay so that the user can verify the spinner renders correctly on the “Login with Apple” button.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
const useFakeAppleLogin = (): ReturnType<typeof useAppleLogin> => { const [isLoading, setIsLoading] = useState(false); const login = useCallback(async () => { if (window.confirm('Login with Apple?')) { setIsLoading(true); await new Promise((resolve) => setTimeout(resolve, 500)); setIsLoading(false); const status = window.confirm('Do you already have an account?') ? ('success' as const) : ('signupRequired' as const); return { status, values: { appleAuthCode: 'apple auth code', appleIdToken: 'apple id token', email: 'test@example.com', firstName: 'First name', lastName: 'last name', }, }; } else { throw new Error('Cancelled by user'); } }, []); return { login, isLoading, }; };
The video below shows different Apple login outcomes in the AuthModal
story. The MethodSelect
component renders an error message if the login attempt fails. Suppose it succeeds, and a matching account is associated with the authentication credentials. In that case, the authentication flow completes, and the modal can be closed (the Storybook story doesn’t show this). If there is no matching account, the authentication flow continues with the CreateAccount
component with prefilled first and last names. In the case of a matching but unlinked account, the component LinkAccount
prompts for a confirmation to link an existing account with the provided authentication credentials.
As with the non-interactive mock implementations for the authentication hooks, the interactive fake implementations are also type-safe and can be succinctly injected into the AuthModal
component without additional ceremonies. Avoiding untyped code also in tests and component explorer stories helps keep maintenance efforts at bay, reduces accidental breakages, and documents the code; inspecting types can help understand the purpose of a piece of code.
Conclusions
Large and aging codebases under active development grow increasingly complex over time. An ever-larger share of the engineering effort goes into refactoring and quality assurance processes to prevent regressions and simplify introducing new features. Keeping the code modular is paramount in managing complexity, and dependency injection can play a crucial role by introducing clean and minimal interfaces where beneficial.
Injecting React component dependencies carries some overhead and may make the codebase as a whole harder to follow when not reusing type signatures. Self-contained components, however, may require less maintenance as changes elsewhere do not necessarily cascade to them. In the end, tradeoffs need to be weighed, and it is usually better to aim for pragmatic benefits than ideological purity.
Injecting hook dependencies, while possible, albeit somewhat unorthodox, requires careful consideration. The hook call order must remain the same during the lifetime of a component. Satisfying that, injecting hook dependencies into components instead of importing them has many benefits. It can decouple side effects from components, each call site can type-safely specify a different implementation for dependencies, and type-unsafe module mocking becomes unnecessary in tests and component explorer stories. If hooks are passed through props (whether curried or not), components advertise their intent with their type signature. The call site controls all outside interaction, provided that a well-behaved component does not have side effects nor observe the global state except through its explicit dependencies.
We experimented with hook dependency injection while renewing our authentication modal. Although the hooks were not very complex, and we likely could have achieved similar test coverage and type safety without injecting them, it was an educational journey for our team, during which we learned a lot about the internals of React hooks. After this exercise, injecting hook dependencies remains a viable strategy in our playbook whenever it can help keep code maintenance at bay so that we can focus on creating delightful features and experiences for our users.
If you're the kind of engineer who relishes the opportunity to experiment with new strategies, tackle complex codebases, and continuously learn and grow, we'd love to have you on board our team! Browse our open jobs 💙