← 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:

  • Design the user flow
  • Create low-fi prototypes
  • Identify the components that make up the designs
  • Identify the data used to populate each component
  • Create the data structure that will store the page’s state
  • Identify actions that can be taken by users, and decide how those actions affects the state of the page
  • Create asynchronous action dispatchers to handle API calls
  • Create individual stateless functional components that render content based on the state
  • Combine the individual components to make a PageView
  • Wire up the PageView inside a Page

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.

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.

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.

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

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.

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.

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.

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.

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.

If no design system exists, you can save a lot of effort by adopting an off-the-shelf component library like Chakra-UI [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.

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.

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:

  • The newsletter to display information about it.
  • The user’s email address as they type it into the form.
  • Whether the user’s email address looks valid, e.g. it has an @ symbol in it, and is over 3 characters long.
  • Something to decide whether the subscribe button is enabled or not.
  • Something to decide whether to display a spinner while the subscription API call is happening.
  • Something to decide whether to display an error message if the subscription API call fails.
  • Something to decide whether to display a success message if the subscription API call has succeeded.

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:

  • Type their name into the box.
  • Click the subscribe button.

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.

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:

  • Typing in the email address box executes the onEmailChanged function that gets passed in.
  • Clicking the “Subscribe” button executes the onSubscribeClicked function that gets passed in.

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:

  • A state model.
  • A reducer, that receives actions that updates the state.
  • Components, that render HTML based on the state.

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]

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:

  • Design the user flow
  • Create low-fi prototypes
  • Identify the components that make up the designs
  • Identify the data used to populate each component
  • Create the data structure that will store the page’s state
  • Identify actions that can be taken by users, and decide how those actions affects the state of the page
  • Create asynchronous action dispatchers to handle API calls
  • Create individual stateless functional components that render content based on the state
  • Combine the individual components to make a PageView
  • Wire up the PageView inside a Page