jordanlos

Jordan Los

Posted on February 17, 2022

Planning for Complexity

My team recently had to put together a large component for uploading CSVs that had a lot of moving parts. S3 uploads, background workers, GraphQL polling, and real-time progress bars were all involved. The final result looked like this:

CSV Upload Gif

The main challenge was how to structure the component so that it was readable, testable, and easy to work with later.

Treat Complexity like Heavy Industry and Zone It Off

Building anything worthwhile will eventually produce some undesirable byproduct. If you want a bustling city of a million-plus people living in sanitary, climate-controlled homes with food, water, and electricity, then you’re going to need a certain amount of heavy industry. But heavy industry produces all sorts of things we don't want near our homes and businesses. Imagine a Sriracha plant going up next door or a waste management facility beside your office. How a city zones for industrial facilities has a significant impact on how you experience that city.

Code may not produce noise and pollution but it does generate a lot of complexity. If we don’t implement some kind of zoning for complex parts of our code, our components may start to feel like a city without land-use regulations. Ultimately, we want strategies for dealing with complexity that keep our interactions with it to a minimum.

Here are four strategies we can use to zone off complexity in larger components:

  1. Declarative State Transitions
  2. Side Effect Containment
  3. Keep Your Return Statement Above the Fold
  4. Descriptive Functions Instead of Comments

Declarative State Enums

Before telling you anything about the ImportCard component from above, see how much you can figure out by just reading the code:

enum UIStates {
  Idle,
  Importing,
  Done,
}

function ImportCard({ initialState = UIStates.Idle }: ImportCardProps) {
  const [uiState, setUIState] = useState<UIStates>(initialState);
  const [workerId, setWorkerId] = useState<string | undefined>();
  const [bannerState, setBannerState] = useState(initialBannerState);

  switch (uiState) {
    case UIStates.Idle:
      return <IdlingCard handleImport={handleImport} />;
    case UIStates.Importing:
      return <ImportingCard onTransition={handleTransitionToDone} workerId={workerId} />;
    case UIStates.Done:
      return <DoneCard showBanners={bannerState} />;
    default:
      assertUnreachable(uiState);
  }

  // I'll show the mutation, handleImport, and handleTransitionToDone a little later in the article.
}
Enter fullscreen mode Exit fullscreen mode

And here are the tests:

describe("ImportCard", () => {
  it("initializes in the idling state", async () => {
    const { getByTestId } = render(<ImportCard />);
    expect(getByTestId("begin-import")).toBeDefined();
  });

  it("transitions to importing state when import is started", async () => {
    const { getByTestId, findByTestId } = render(<ImportCard />);
    fireEvent.click(getByTestId("begin-import"));
    await waitFor(async () => {
      expect(await findByTestId("awaiting-import")).toBeDefined();
    });
  });

  it("transitions to done state when import is completed", async () => {
    const { getByTestId } = render(<ImportCard initialState={UIStates.Importing} />);
    fireEvent.click(getByTestId("awaiting-import"));
    await waitFor(() => {
      expect(getByTestId("done")).toBeDefined();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Despite not knowing anything about the underlying components or what kind of problems they might contain, the intent of the ImportCard component is hopefully clear. In fact, if I hadn’t already told you about all the difficulties underneath you might not even know this was a very complicated component. That nice, quiet suburb may be powered by a turbine larger than your house, but that fact never even needs to cross your mind.

Creating Declarative Enums

The first strategy we used was declarative state enums. Declarative state enums involve:

  1. An enum specifying the possible states
  2. A useState hook that initialized with the state enum
  3. A switch statement to return the appropriate components

Declarative enums go a long way in preventing someone from misunderstanding what can happen in your component. And while Typescript's enums aren’t as powerful as the ones in languages like Rust, we can include a little helper function to ensure our switch statement is exhaustive, requiring a case for every value in the enum. This helper function ensures if someone adds another value to the enum, the Typescript compiler will raise an error:

The function definition is this:

function assertUnreachable(arg: never): never {
  throw new Error("Default should be unreachable");
}  
Enter fullscreen mode Exit fullscreen mode

and gets used in the switch statement like this:

switch (uiState) {
  // Handle the cases. . .
  default:
    assertUnreachable(uiState);
}
Enter fullscreen mode Exit fullscreen mode

Testing Declarative Enums

Since the ImportCard just swaps between child components, this makes them easy to mock. The mocked components are just a button with a test id and a handler for the state transition function that gets passed down:

// ImportCard.test.tsx

jest.mock(
  "path/to/IdlingCard",
  () => {
    return {
      IdlingCardContent: jest.fn((props: IdlingCardProps) => {
        const mockImport = props.handleImport();
        return <button data-testid="begin-import" onClick={mockImport} />;
      }),
    };
  },
);
Enter fullscreen mode Exit fullscreen mode

I’ll admit the mocked button with a data-testid is a bit of a workaround. A better solution here would be to just have some way to call the handleImport function without worrying about how the subcomponents actually accomplish that task. However, the React Testing Library intentionally doesn’t allow us the ability to directly access a component's props because it wants us to focus on testing visible UI. Normally, testing UI is an excellent strategy. But with high-level components like the ImportCard, testing UI elements too tightly couples the parent component's behaviour with the child component's implementation. A mock button avoids those implementation details and lets us trigger the desired function prop directly.

Another way our tests benefit from using declarative state enums is that we can specify the starting state of our ImportCard. By making the initialState a prop with a default value we can start in a different component state than the default initial state by just specifying it when rendering the component:

const { getByTestId } = render(<ImportCard initialState={UIStates.Importing} />);
Enter fullscreen mode Exit fullscreen mode

Side Effect Containment

S3 uploads, triggering Rails workers, and background polling for progress updates all involve a certain type of complexity called a side effect. If you’re not familiar with the concept of side effects, one way to think of them is this: a function is either returning a value or causing something to happen outside of the function itself. Functions that only return values are simple. Functions that trigger something outside of themselves are not; they are coupled with code you might not control.

But we still want some side effects. What we don't want is for these side effects to be mingled with the rest of our code.

Let's look at how we isolate and zone off the side effects in the ImportingCard:

function ImportingCard({
  workerId,
  onTransition,
}: ImportingCardProps) {
  return (
    <CardContents
      notifications={
        <ImportProgressBar workerId={workerid} nextUIState={onTransition} />
      }
      nextStateButton={<ImportPendingButton />}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode
describe("ImportingCard", () => {
  it("call the nextUIState call when the import is complete", () => {
    const mockOnTransition = jest.fn();
    const { getByTestId } = render(
      <ImportingCard onTransition={mockOnTransition} />,
    );
    getByTestId("import-complete").click();
    expect(mockOnTransition).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

All polling and progress updates can be sealed and tested inside the ImportProgressBar component. CardContents isolates the shared HTML used in each state of the ImportCard. And because ImportProgressBar and the CardContents components have now isolated their complexities from each other, we can mock the ImportProgressBar without worrying about testing anything but the ImportingCard's behaviour.

Keep Your Return Statement Above the Fold

So far we’ve shown how to zone off complexity between components but that same strategy also works within a component.

Print newspapers put the most critical headlines above the fold where interested passersby can see them. Likewise, we can place our most critical sections of code (usually the return statement) near the top of a component. Giving the return statement a place of prominence lets a reader get straight to the point and drill down for details only if they need them.

Take a second look at the ImportCard component below:

enum UIStates {
  Idle,
  Importing,
  Done,
}

function ImportCard({ initialState = UIStates.Idle }: ImportCardProps) {
  const [uiState, setUIState] = useState<UIStates>(initialState);
  const [workerId, setWorkerId] = useState<string | undefined>();
  const [bannerState, setBannerState] = useState(initialBannerState);

  switch (uiState) {
    case UIStates.Idle:
      return <IdlingCard handleImport={handleImport} />;
    case UIStates.Importing:
      return <ImportingCard onTransition={handleTransitionToDone} workerId={workerId} />;
    case UIStates.Done:
      return <DoneCard showBanners={bannerState} />;
    default:
      assertUnreachable(uiState);
  }

  /* As promised */

  const [importItems] = useMutation<ImportItemsMutation>(
    IMPORT_MUTATION,
    {
      onCompleted: importMutationPayload => {
        setWorkerId(importMutationPayload.importItems.workerId);
      },
    },
  );

  function handleTransitionToDone(finalWorkerState: BackgroundWorker) {
    setUIState(UIStates.Done);
    updateBannerOnCompletion(finalWorkerState, setBannerState);
  }

  async function handleImport(uploadUrl: string) {
    await importItems({
      variables: {
        uploadUrl: uploadUrl,
      },
    });
    setUIState(UIStates.Importing);
  }
}
Enter fullscreen mode Exit fullscreen mode

All the component’s logic gets moved “below the fold” into intent-describing functions. Since Javascript uses function declaration hoisting we can use the functions in the return statement before they are defined. This does not, however, apply to function expressions (i.e. arrow functions) which are not hoisted. Arrow functions are a great language feature, but we don't want to force readers to look at the complex parts of our component before seeing our intent.

Descriptive functions instead of comments

Lastly, there are always some lines of code that are complicated, error-prone, and essential. They might involve tricky math, object properties with misleading names, an unintuitive but necessary design choice, or anything non-obvious that makes you feel you should write a comment. Isolate that comment-worthy code into a function describing the intent and move it somewhere else. Spare the readers of your code (including future you) from having to figure out what the code is trying to accomplish by telling them directly.

There weren’t any examples of this in the ImportCard, but comment-worthy code that can be zoned off in a function happens often enough I’ve included it anyway.

Conclusion

Strategies like the ones above often become more valuable after you’ve got something working. Complexity in software, like industrial facilities in cities, occurs because we’re building something big and useful. If we let that complexity operate just anywhere, we expose the rest of our code to problems more often than we need to. But unlike cities, we can choose how to handle complexity after we’ve created it.

About Jobber

We're hiring for remote positions across Canada at all software engineering levels!

Our awesome Jobber technology teams span across Payments, Infrastructure, AI/ML, Business Workflows & Communications. We work on cutting edge & modern tech stacks using React, React Native, Ruby on Rails, & GraphQL.

If you want to be a part of a collaborative work culture, help small home service businesses scale and create a positive impact on our communities, then visit our careers site to learn more!

💖 💪 🙅 🚩
jordanlos
Jordan Los

Posted on February 17, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Planning for Complexity
jobbertech Planning for Complexity

February 17, 2022