Massimiliano Mirra

Notes



















Date:
Tags: react
Status: finished

Expressive components in vanilla React, part 2: IO objects

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.

Where should I create the IO object?

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).

Summary

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.

I write in the hope of making other people’s developer journey smoother and more rewarding. If you noticed a mistake or have suggestions on how to improve this article, please let me know. If you think this article can be useful to others, please consider sharing it:

Stay in touch

© 2020-2024 Massimiliano Mirra