💾 Archived View for capsule.adrianhesketh.com › 2022 › 06 › 17 › process-for-creating-a-react-page captured on 2023-04-26 at 13:06:25. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2023-01-29)

-=-=-=-=-=-=-

capsule.adrianhesketh.com

home

Process for creating a React / Next.js page

I've recently been working with a team that was new to TypeScript, React and Next.js to build a Web application and found that one of the challenges the team faced was understanding the workflow of how to design the page, break the process down into tasks, and test the outputs.

In this post, I'll cover the process I use. Each of the headings is a step in the process:

The example scenario is a web application that allows people to list email newsletters, and sign up to ones that they're interested in.

Design the user flow

The first stage is to design the overall user journey.

Typically, I'll do a first pass on a whiteboard / Miro board with the product owner, development team, and anyone else that's going to contribute to the product.

At this stage, we don't really care about how it looks, and it doesn't have to include everything, it's just about getting the overall shape in place.

It's critical to have the key stakeholders present for this session. If we're building a website to sell cars, the developers might guess that there needs to be a selection screen, but the product owner might tell us that there actually needs to be a search screen, and user researchers might tell us that customers really want to have the option to search by number of seats in the car.

Start with boxes and lines, don't bother with user interface elements, because you're likely to be changing things around a lot, and too much detail will get in the way.

[0]

Since this is a collaborative excercise, Miro [1] is a good choice to draw it out. Everyone can get around the virtual whiteboard and draw out the process flow.

[1]

Create low-fi prototypes

Once you've got everyone agreed on a basic flow, you can start putting together a rough set of screens to describe the inputs, and buttons you're likely to have.

This might take a few attempts to get right, and even once the first version is released, elements of a journey will be regularly updated to try out new ideas, incorporate feedback from customers, and take into account data obtained from analytics (e.g. customers drop out of the journey on a particular screen).

Tools like Miro [1] and Balsamiq [2] can be useful at these stages. I like Miro because it's a really collaborative way of working.

[1]

[2]

Create visual designs of the flow

With the rough outline agreed on, a designer can start to build out higher fidelity prototypes, typically in a tool like Figma [3] or Sketch [4].

[3]

[4]

The designs will be shared around the team (and often with customers) for feedback.

One thing that you don't really get with these sorts of protoypes is much of an indication of how "interactions" with the pages work. Some designers will make clickable prototypes that allow you to walk through the journey, while others will just product visual designs, so you'll often need to infer transition states.

For example, you might not see a "loading" indicator, or get any indication of how things should look if an API call fails. On mobile devices, when people are travelling, they're likely to have patchy network access, so network errors are bound to be encountered.

We'll have to think about potential technical gaps of the design, and fill them ourselves, or ask designers to develop patterns.

Hopefully, your designer will do a better job than my designs.

[5]

Identify the components that make up the designs

What elements do you see on the screen? It's likely to include individual buttons, text, inputs, validation messages and icons.

We'll want to have a consistent visual look and feel for all of these.

Layout standards are also important. For example, the "Next" button might always be the rightmost button, while the "Cancel" button is the leftmost button in any button group [6], while the main action point should be a consistent colour.

[6]

Some product teams will already have a "design system", a set of ready-made user interface and website elements that can be used to construct applications, along with the principles and thinking that was used to create it. If one is available, you should usually use it.

Design systems like gov.uk's [7] provide a great deal of consistency, and ensures that each component is optimised for accessibility and ease of use.

[7]

Design systems are made available to teams in lots of different ways. Some organisations provide technology-agonstic representations in HTML, and it's up to you to make that work with your programming frameworks or libraries, while others provide implementations in a popular technology like React or Vue.

If I need to build a component library to implement a design system, I like to use Storybook [8], since it allows you to preview the results, and ship a library of React components as a private NPM package, to enable it to be easily updated when issues are found, or designs change.

[8]

Typically, you're looking to build out an atomic design system [9] that allows you to build up pages out of smaller components.

It doesn't make sense to create components for application-specific functionality such as searching for a vehicle, but it does make sense to create a reusable button, layout or header.

[9]

If no design system exists, you can save a lot of effort by adopting an off-the-shelf component library like Chakra-UI [10].

[10]

Identify the data used to populate each component

As an application developer, I'm typically concerned with displaying data to the user, handling user input, and processing requests to modify or delete data.

The first step is to identify where data is coming from, and where it's going to.

The first page needs to display a list of the email newsletters. To do this, we're probably going to need to access a REST API that can provide the data we need in JSON format.

Since our user interface shows the name of newsletter, and a description, the REST API needs to return those fields, and our page data needs to store them.

I usually write the HTTP paths, verbs, and example JSON structure on the Miro board alongside the UI for discussion with the team.

newsletters-view.png

On the subscribe page, we're looking at a bit more information, but it's a specific newsletter. Maybe we'll need to use a different API to get the details, but we'll need to pass the ID of the newsletter. So, in this case, the source of the ID might be the current URL, e.g. `https://api.example.com/newsletters/{id}`

There's also a bit for the user to enter their email address, and click the "Subscribe" button.

newsletter-view.png

Create the data structure that will store the page's state

For the rest of this, I'll focus in on the subscribe page, since it's more complex.

Working back from what's on the UI, we can work out what the data structure for the page should be.

We need to include:

Rather than model the toggles as individual booleans (React's `useState` will send you in this direction), I like to think about the form as a state machine. There are a number of states the UI can be in, and the actions of the user cause events to happen which change the state.

So the status of the page is defined as a set of strings.

type Status = "INITIAL" | "INVALID" | "SUBSCRIBING" | "ERROR" | "SUBSCRIBED"

And the data is defined as an interface, along with a `createInitialState` function to set up default and required values.

// All of the data required to display the page.
interface State {
  newsletter: Newsletter
  email: string,
  status: Status,
}

// Create the initial state of the page, populated with required values.
export const createInitialState = (newsletter: Newsletter): State => ({
  newsletter,
  email: '',
  status: "INITIAL",
})

Identify actions that can be taken by users, and decide how those actions affects the state of the page

On the subscribe page, the user can do two things:

In the case of the email changing, we might decide that the form is invalid if the email address doesn't look valid. Or we might change the `status` from `SUBSCRIBING` to `ERROR` if the subscription completes, but has an error.

Clicking the subscribe button is more complex. It will trigger an API call that might take a second or two to complete. So to model this, I break it up into a 'started' and 'completed' event.

When the button is clicked, the 'started' action is processed, and when the API call completes, the 'completed' action is processed.

This can be modelled as a set. There's 3 actions, each with associated data.

export type Action =
  | { type: 'emailChanged', email: string }
  | { type: 'subscriptionStarted' }
  | { type: 'subscriptionCompleted', success: boolean, error?: Error }

To process these actions, we can write a function that takes in the current state, and an action, and returns the updated state. This is commonly called a reducer.

const isEmailValid = (email: string) => email.length > 3 && email.indexOf('@') > 0

// A function that determines how Actions modify the state.
export const reducer: Reducer<State, Action> = (state, action) => {
  switch (action.type) {
    case 'emailChanged': {
      const status = isEmailValid(action.email) ? state.status : "INVALID"
      return {
        ...state,
        email: action.email,
        status: status,
      }
    }
    case 'subscriptionStarted':
      return {
        ...state,
        status: 'SUBSCRIBING',
      }
    case 'subscriptionCompleted': {
      const status = action.error ? 'ERROR' : 'SUBSCRIBED'
      return {
        ...state,
        status,
      }
    }
  }
}

With this design, we can test the logic of the page before we've built a single visual component by creating the initial state, and pushing actions through the reducer function.

const fakeNewsletterId = "abc123"

const fakeNewsletter: Newsletter = {
  id: fakeNewsletterId,
  title: "newsletter",
  desc: "Lorem ipsum...",
  img: "test.jpg"
}

describe('subscribe reducer', () => {
  describe('emailChanged', () => {
    it('updates the email address', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = {
        type: 'emailChanged',
        email: 'test@test.com',
      }
      const updatedState = reducer(initial, action)

      expect(updatedState.email).toEqual('test@test.com')
    })
    it('updates the status to VALID if the email is invalid', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = {
        type: 'emailChanged',
        email: 'test@test.com',
      }
      const updatedState = reducer(initial, action)

      expect(updatedState.email).toEqual('test@test.com')
      expect(updatedState.status).toEqual('VALID')
    })
    it('updates the status to INVALID if the email is invalid', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = {
        type: 'emailChanged',
        email: 't',
      }
      const updatedState = reducer(initial, action)

      expect(updatedState.email).toEqual('t')
      expect(updatedState.status).toEqual('INVALID')
    })
  })
  describe('subscriptionStarted', () => {
    it('updates the state to SUBSCRIBING', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = { type: 'subscriptionStarted' }
      const updatedState = reducer(initial, action)

      expect(updatedState.status).toEqual('SUBSCRIBING')
    })
  })
  describe('subscriptionCompleted', () => {
    it('updates the state to SUBSCRIBED on success', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = { type: 'subscriptionCompleted', success: true, error: undefined }
      const updatedState = reducer(initial, action)

      expect(updatedState.status).toEqual('SUBSCRIBED')
    })
    it('updates the state to ERROR if there was a problem', () => {
      const initial = createInitialState(fakeNewsletter)
      const action: Action = { type: 'subscriptionCompleted', success: false, error: new Error("unknown error") }
      const updatedState = reducer(initial, action)

      expect(updatedState.status).toEqual('ERROR')
    })
  })
})

Create asynchronous action dispatchers for API calls

As mentioned in the previous section, API calls and other background activities take time to complete, so they can't be handled within the reducer function, or the UI would become unresponsive.

To deal with the process of sending the initial `subscriptionStarted` action to the reducer, and then following up with a `subscriptionCompleted` action when the API completes, an action dispatcher can be created.

The `createSubscriber` function shown below is a function that returns another function. This pattern is called a "higher order function".

Calling `createSubscriber` with a `dispatch` argument (part of React, we'll get to this) returns a function that has an `id` and `email` parameter.

When you call the function that's returned from `createSubscriber`, the function dispatches a `subscriptionStarted` action, and then a `subscriptionCompleted` action when the API call completes.

export const createSubscriber = (dispatch: Dispatch<Action>) => async (id: string, email: string) => {
  try {
    dispatch({
      type: 'subscriptionStarted',
    })
    const res = await fetch(`/api/newsletter/${encodeURIComponent(id)}/subscribe`, {
      method: 'POST',
      body: JSON.stringify({ email }),
    })
    if (!res.ok) {
      throw new Error(`Unexpected ${res.status} response from subscription API`)
    }
    dispatch({
      type: 'subscriptionCompleted',
      success: true,
      error: undefined,
      ...await res.json(),
    })
  } catch (error) {
    dispatch({
      type: 'subscriptionCompleted',
      success: false,
      error: error as Error,
    })
  }
}

It's possible to test this function by mocking the `fetch` API, and passing in a mock function in place of the `dispatch` parameter.

Developing an asynchronous action dispatcher like this helps to avoid having state or application logic embedded in your React components. This functionality is completely separate from React, so it can be tested separately.

Create stateless functional components

With the application logic complete, we can move on to the user interface.

First, build out each component. There are two on the subscribe page.

subscribe.png

NewsletterView

The first component is the `NewsletterView`, which is the simplest of the two. It's a function that takes in a `NewsletterViewProps` and returns a React component that displays the newsletter.

// The stateless functional component that displays the newsletter information.
export interface NewsletterViewProps {
  newsletter: Newsletter
}

export const NewsletterView: FC<NewsletterViewProps> = ({ newsletter }) => (
  <Box borderWidth='1px' borderRadius='lg' p={5}>
    <Flex>
      <Box>
        <h2>{newsletter.title}</h2>
        <Text fontSize='sm'>{newsletter.desc}</Text>
      </Box>
    </Flex>
  </Box>
)

SubscribeForm

The second component is the form, which is more complex, because you can carry out some actions in a form.

Rather than having internal state within the component (e.g. using `useState`), the actions taken based on interacting with the form are passed in as props and used by the component.

Meaning that:

The status is used to determine whether to disable the button, or display the spinner, error message, or success message.

export interface SubscribeFormProps {
  emailAddress: string,
  onEmailChanged: (s: string) => void,
  onSubscribeClicked: () => void,
  status: Status,
}

export const shouldDisableSubscribeButton = (status: Status) => status === 'INITIAL' || status === 'INVALID' || status == 'SUBSCRIBING'

export const SubscribeForm: FC<SubscribeFormProps> = ({ emailAddress, onEmailChanged, onSubscribeClicked, status }) => (
  <Box borderWidth='1px' borderRadius='lg' p={5}>
    <Flex>
      <Input
        maxLength={100}
        name='email'
        onChange={(e) => onEmailChanged(e.target.value)}
        placeholder="email"
        type="email"
        value={emailAddress}
      />
      <Button disabled={shouldDisableSubscribeButton(status)} onClick={() => onSubscribeClicked()}>Subscribe</Button>
      {status === 'SUBSCRIBING' && (
        <Spinner />
      )}
      {status === 'ERROR' && (
        <Alert status='error'>Error subscribing, please try again</Alert>
      )}
      {status === 'SUBSCRIBED' && (
        <Alert status='success'>Thanks for subscribing!</Alert>
      )}
    </Flex>
  </Box>
)

Just to press the point again. Clicking the button or typing in the email box doesn't _do_ anything except run the `onSubscribeClicked` function that's passed in as a prop.

There is no `useState` or other internal state (variable) in the `Subscribe` component except the props (`emailAddress`, `onEmailChanged` etc.). This means it's _stateless_.

The `Subscribe` component is a function (not a class), which takes in parameters and returns a React element. This means that it's a _functional component_.

So, it's a _stateless functional component_, or `FC` which takes in `SubscribeProps`, i.e. `FC<SubscribeProps>` and returns a React Element.

Using stateless functional components makes testing easy

The approach of using stateless function components allows `@testing-library/react` to render the `SubscribeForm` without complex mocking.

Any behaviour of the component can be tested by passing in a simple state object, and since the `onSubscribeClicked` handler is passed in, a simple `jest.fn()` mock can be used to check that the expected actions occurred.

describe('SubscribeForm', () => {
  it('executes the onClickEvent when the subscribe button is clicked', async () => {
    // Arrange.
    const onClick = jest.fn()
    render(<SubscribeForm
      emailAddress="test@example.com"
      onEmailChanged={() => null}
      onSubscribeClicked={onClick}
      status={"VALID"}
    />)

    // Act.
    fireEvent.click(screen.getByText('Subscribe'))

    // Assert.
    expect(onClick).toHaveBeenCalled()
  })
  it('disables the button if the form is invalid', async () => {
    // Act.
    render(<SubscribeForm
      emailAddress="t"
      onEmailChanged={() => null}
      onSubscribeClicked={() => null}
      status={"INVALID"}
    />)

    // Assert.
    screen.getByText('Subscribe').hasAttribute("disabled")
  })
  it('disables the button if the API call is in progress', async () => {
    // Act.
    render(<SubscribeForm
      emailAddress="t"
      onEmailChanged={() => null}
      onSubscribeClicked={() => null}
      status={"SUBSCRIBING"}
    />)

    // Assert.
    screen.getByText('Subscribe').hasAttribute("disabled")
  })
  it('shows the spinner when the subscription API call is happening', async () => {
    // Arrange.
    // Act.
    render(<SubscribeForm
      emailAddress="test@example.com"
      onEmailChanged={() => null}
      onSubscribeClicked={() => null}
      status={"SUBSCRIBING"}
    />)

    // Act.
    expect(screen.queryByText('Loading...')).toBeInTheDocument()
    expect(screen.queryByText('Error subscribing, please try again')).toBeNull()
    expect(screen.queryByText('Thanks for subscribing')).toBeNull()
  })
  it('shows an error message if the subscription API call fails', async () => {
    // Arrange.
    // Act.
    render(<SubscribeForm
      emailAddress="test@example.com"
      onEmailChanged={() => null}
      onSubscribeClicked={() => null}
      status={"ERROR"}
    />)

    // Act.
    expect(screen.queryByText('Loading...')).toBeNull()
    expect(screen.queryByText('Error subscribing, please try again')).toBeInTheDocument()
    expect(screen.queryByText('Thanks for subscribing')).toBeNull()
  })
  it('shows an success message if the subscription API call succeeds', async () => {
    // Arrange.
    // Act.
    render(<SubscribeForm
      emailAddress="test@example.com"
      onEmailChanged={() => null}
      onSubscribeClicked={() => null}
      status={"SUBSCRIBED"}
    />)

    // Act.
    expect(screen.queryByText('Loading...')).toBeNull()
    expect(screen.queryByText('Error subscribing, please try again')).toBeNull()
    expect(screen.queryByText('Thanks for subscribing!')).toBeInTheDocument()
  })
})

Combine individual components to make a `PageView`

The two components need to be combined in a single component to make up the page.

The new component displays both the `NewsletterView` and the `SubscribeForm` components.

Breaking down pages and large components down into smaller individual components is a key technique.

export interface SubscribePageViewProps {
  state: State,
  onEmailChange: (s: string) => void,
  onSubscribeClick: () => void,
}

export const SubscribePageView: FC<SubscribePageViewProps> = ({ state, onEmailChange, onSubscribeClick }) => (
  <div className={styles.container}>
    <Head>
      <title>{state.newsletter.title}</title>
      <link rel="icon" href="/favicon.ico" />
    </Head>
    <main>
      <NewsletterView newsletter={state.newsletter} />
      <SubscribeForm
        status={state.status}
        emailAddress={state.email}
        onEmailChanged={(e) => onEmailChange(e)}
        onSubscribeClicked={() => onSubscribeClick()}
      />
    </main>
  </div>
)

Wire up components to a page

The final step is to connect everything together. So far, we have:

Now we need something to connect the state model, reducer and the components together.

Next.js can render a React component on the server side and output the HTML, this is called a `NextPage`.

getServerSideProps

The `NextPage` can take props, but these need to be populated by the `getServerSideProps` function, which, as you've probably guessed, runs on the server, not in the user's browser.

The props we create is the initial state of the page - the state of the page before the user interacts with it.

We already created a function called `createInitialState`, which requires a newsletter from the newsletter API to be passed in.

Next.js allows us to use path parameters to extract the ID of the newsletter from the URL. [11]

[11]

So it's just a case of getting the newsletter ID, using it to call the `newsletterGet` function, and using the `Newsletter` we get back to populate the props.

export const getServerSideProps: GetServerSideProps<State> = async (context) => {
  const id = context.params?.id
  if (!id || typeof id !== 'string') {
    throw new Error('id path parameter not found')
  }
  try {
    // Get the newsletter from the API on the server side.
    const newsletter = await newsletterGet(id)
    return {
      props: createInitialState(newsletter),
    }
  } catch (error) {
    log.error('error getting newsletter', error as Error, { page: 'subscribe', id })
    throw new Error(`failed to load newsletter ${id}`)
  }
}

Next.js will automatically use a `getServerSideProps` function to populate the page if the `getServerSideProps` function is exported.

useReducer

The final step is to use the `useReducer` React hook [12] to wire up the reducer to the initial state.

https://reactjs.org/docs/hooks-reference.html#usereducer [12]

The `useReducer` hook returns the current state, and the `dispatch` function used to send actions to the reducer that modify the state.

React takes care of updating the user interface when the state changes for us.

The `onEmailChange` function, and `onSubscribeClick` function are created to wire up the UI components to dispatching an `emailChanged` action, and start the asynchronous action dispatcher that makes API calls.

Although there's a few moving parts, the core idea is simple.

We have initial state, and this state is fed to components which render content. Components notifiy React about user actions by sending them via the `dispatch` function. React runs the Reducer and checks whether the state has been updated. If the state has changed, React triggers a re-render of anything that needs to be redrawn as a result of the change.

const SubscribePage: NextPage<State> = (initialState) => {
  const [state, dispatch] = useReducer(reducer, initialState)
  const onEmailChange = (email: string) => dispatch({
    type: 'emailChanged',
    email,
  })
  const subscribe = useMemo(() => createSubscriber(dispatch), [])
  const onSubscribeClick = () => subscribe(state.newsletter.id, state.email)
  return SubscribePageView({ state, onEmailChange, onSubscribeClick })
}

export default SubscribePage

Summary

In this post, I've outlined my approach to building a Next.js page from start to finish:

More

Next

Migrating Go and Node.js Fargate tasks and Lambda functions to Graviton ARM processors with CDK

Previous

From linear to binary search in Go

Home

home