May 15, 2019

Build Isomorphic Apps with Next.js

by Camilo Reyes

Build Isomorphic Apps with Next.js

React opens ways to render components anywhere through its virtual DOM. As a result, rendering is not tightly-coupled to a browser. Next.js grabs a hold of this idea and unlocks server-side rendering by default — using React components that render on both client and server.

In this take, we’ll be building a simple timer component. It keeps track of elapsed minutes and seconds and updates once every second. The app is isomorphic, meaning it renders both on the client and server. The same logic gets reused to load the component and fire updates. Universal rendering is done by Next.js.

For example, to fire updates every second:

componentDidMount() {
  this.intervalTimer = setInterval(() => this.increase(), 1000);
}

For Next.js, you are free to use whatever tools already work with React. For this demo, I’ll pick TypeScript, Redux, and Enzyme.

There is some plumbing required in getting Redux and TypeScript to work with Next.js. The App component, for example, needs a custom extension so the store can go in a <Provider />. This is the Redux store necessary when it loads the component. App props need both the default set that comes with Next.js and the custom store. This same store is used for universal rendering in Next.js. To set up TypeScript, Next.js has a plugin @zeit/next-typescript one can configure. We’ll forgo all the plumbing to keep the sample code focused. Feel free to check out the GitHub repo for more details.

When the component is ready in Next.js, you render it through an index route. For example:

const IndexPage: React.FC = () => <Timer />;

Next.js has a pages folder where each .tsx or .ts file becomes a route. The rest gets rendered by the framework and React. The index page doesn’t concern itself with prop parameters. These parameters are encapsulated in a state machine like Redux.

TypeScript

The timer needs a state object to keep track of the minutes and seconds. Dispatched actions will increase the timer in seconds. The component props encapsulate both current state and the action function. For TypeScript, we can build these concepts using type annotations. This allows the app to scale as it grows with more requirements. Having a set of static types makes it easier to refactor and make changes at will.

For example:

interface TimerState {
  seconds: number;
  minutes: number;
}

const INCREASE_SECONDS = 'INCREASE_SECONDS';

interface IncreaseTimerAction {
  type: typeof INCREASE_SECONDS;
}

type TimerActionTypes = IncreaseTimerAction;

interface TimerProps {
  timer: TimerState;
  increaseTimer: () => void;
}

interface ReduxAppProps extends AppProps {
  reduxStore: Store;
}

Note ReduxAppProps extends the default Next.js AppProps type. This is how we define a custom Redux store. For dispatched actions, we encapsulate all actions through a single type. If there are more actions, add it with a union type. In TypeScript, you do this through a pipe, for example, Action1Type | Action2Type. This keeps the reducer from having to worry about too many action types.

Protect your React App with Jscrambler

Redux

For Redux, the biggest piece is the reducer. This is where dispatched actions go to get the next state. The reducer gets the initial state and the dispatched action. The initial state comes from a default parameter when it first loads. We’ll use the types declared in TypeScript to nail down type contracts. This adds a way of communicating intent in the reducer with each type. There can be many action types going into the reducer, which makes the code scalable.

For example:

const reducer = (state = initialTimerState,
  action: TimerActionTypes): TimerState => {
  switch (action.type) {
    case INCREASE_SECONDS:
      const isOverAMinute: boolean = state.seconds >= 59;

      return {
        seconds: isOverAMinute
          ? 0 : state.seconds + 1,
        minutes: isOverAMinute
          ? state.minutes + 1 : state.minutes
      };

    default:
      return state;
  }
};

This handles the logic of rolling the seconds over when it goes over a minute. Note that, with each call, we return a new state object. Next.js can execute this code both on the client and the server. There is no special code necessary to get this to work. Also note the use of types using a colon, for example, : TimerState. This tells TypeScript to do type checking during compilation. You typically do npm run type-check to run the compiler. The type checker can run during a build and block any commits that break any contracts.

Enzyme

The timer component shows the minutes and seconds separated by a colon. It pads both with a zero when they are below ten. For example, 00:00. There is a componentDidMount method that starts the timer with a dispatched action. Enzyme can shallow render the component so Jest can verify how the timer renders.

For example:

it('pads minutes and seconds', () => {
  const component = shallow(<TimerComponent
    timer={{seconds: 0, minutes: 0}}
    increaseTimer={() => {}} />);

  expect(component.find('p').text()).toEqual('00:00');
  component.unmount();
});

To clear out the interval set by setInterval, be sure to unmount the component. This calls the componentWillUnmount method. Note the use of find to query the virtual DOM for a p tag. Think of Enzyme as the jQuery for testing React components. Changing the seconds parameter into a string trips the type checker and throws a compiler error. This turns this unit test into sound code that does what the type contracts say it should do.

Jest works with TypeScript out of the box as of the latest version. Be sure to set the Enzyme adapter and check that it matches the React version, for example, enzyme-adapter-react-16. Jest needs to know about this adapter configuration through jest.config.js. Note the component is isolated enough to where it doesn’t depend on Redux nor Next.js.

Conclusion

Working with Next.js is a lot like working with React. If you are familiar with React, then Next.js feels like home.

Next.js offers code splitting, filesystem-based routing, and hot code reloading out of the box. These are some very advanced features that work well with React components. TypeScript plays well with the rest of the tools and does not get in the way.

The app is isomorphic because it does universal rendering. This is super nice to have since it reduces load times in the browser. Meaning, it doesn’t have to wait on JavaScript for the initial page to load.

Lastly, if you're building React applications with sensitive logic, be sure to protect them against code theft and reverse-engineering by following our guide.