Learn the Redux Architecture by Creating the Minimal TODO App on top of NEXT.js

saltyshiomix

Shiono Yoshihide

Posted on October 12, 2019

Learn the Redux Architecture by Creating the Minimal TODO App on top of NEXT.js

In this article, I'll explain the React Redux architecture by creating so simple TODO app which has just only two features (ADD TODO and DELETE TODO).

This is a step by step guide of the example repo here:

GitHub logo saltyshiomix / nextjs-redux-todo-app

A minimal todo app with NEXT.js on the redux architecture

Features

Usage

# installation
$ git clone https://github.com/saltyshiomix/nextjs-todo-app.git
$ cd nextjs-todo-app
$ yarn (or `npm install`)

# development mode
$ yarn dev (or `npm run dev`)

# production mode
$ yarn build (or `npm run build`)
$ yarn start (or `npm start`)
Enter fullscreen mode Exit fullscreen mode

The Point of View

Folder Structures

NEXT.js Structures

.
├── components
│   ├── page.tsx
│   └── todo.tsx
├── next-env.d.ts
├── pages
│   ├── _app.tsx
│   └── index.tsx
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Redux Structures

.
├── actions
│   └── index.ts
├── components
│   ├── page.tsx
│   └── todo.tsx
├── constants
│   └── actionTypes.ts
├── containers
│   └── page.tsx
├── reducers
│   ├── index.ts
│   └── todo.ts
├── selectors
│   └── index.ts
└── store.ts
Enter fullscreen mode Exit fullscreen mode

Whole Structures

.
├── actions
│   └── index.ts
├── components
│   ├── page.tsx
│   └── todo.tsx
├── constants
│   └── actionTypes.ts
├── containers
│   └── page.tsx
├── next-env.d.ts
├── package.json
├── pages
│   ├── _app.tsx
│   └── index.tsx
├── reducers
│   ├── index.ts
│   └── todo.ts
├── selectors
│   └── index.ts
├── store.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Step 1: Hello World

$ mkdir test-app
$ cd test-app
Enter fullscreen mode Exit fullscreen mode

After that, populate package.json and pages/index.tsx:

package.json

{
  "name": "test-app",
  "scripts": {
    "dev": "next"
  }
}
Enter fullscreen mode Exit fullscreen mode

pages/index.tsx

export default () => <p>Hello World</p>;
Enter fullscreen mode Exit fullscreen mode

And then, run the commands below:

# install dependencies
$ npm install --save next react react-dom
$ npm install --save-dev typescript @types/node @types/react @types/react-dom

# run as development mode
$ npm run dev
Enter fullscreen mode Exit fullscreen mode

That's it!

Go to http://localhost:3000 and you'll see the Hello World!

Step 2: Build Redux TODO App (suddenly, I see)

I don't explain Redux architecture! lol

Just feel it, separation of the state and the view.

Define Features (ActionTypes and Actions)

Define the id of the action type in the constants/actionTypes.ts:

export const TODO_ONCHANGE = 'TODO_ONCHANGE';
export const TODO_ADD = 'TODO_ADD';
export const TODO_DELETE = 'TODO_DELETE';
Enter fullscreen mode Exit fullscreen mode

And in the actions/index.ts, we define the callbacks to the reducers:

(Just define arguments and return data. Actions won't handle its state.)

import {
  TODO_ONCHANGE,
  TODO_ADD,
  TODO_DELETE,
} from '../constants/actionTypes';

export const onChangeTodo = (item) => ({ type: TODO_ONCHANGE, item });

export const addTodo = (item) => ({ type: TODO_ADD, item });

export const deleteTodo = (item) => ({ type: TODO_DELETE, item });
Enter fullscreen mode Exit fullscreen mode

State Management (Reducers)

In the reducers/todo.ts, we define the initial state and how to handle it:

import {
  TODO_ONCHANGE,
  TODO_ADD,
  TODO_DELETE,
} from '../constants/actionTypes';

export const initialState = {
  // this is a TODO item which has one "value" property
  item: {
    value: '',
  },
  // this is a list of the TODO items
  data: [],
};

export default (state = initialState, action) => {
  // receive the type and item, which is defined in the `actions/index.ts`
  const {
    type,
    item,
  } = action;

  switch (type) {
    case TODO_ONCHANGE: {
      // BE CAREFUL!!!
      // DON'T USE THE REFERENCE LIKE THIS:
      //
      //     state.item = item;
      //     return state; // this `state` is "previous" state!
      //
      // Please create a new instance because that is a "next" state
      //
      return Object.assign({}, state, {
        item,
      });
    }

    case TODO_ADD: {
      // if the `item.value` is empty, return the "previous" state (skip)
      if (item.value === '') {
        return state;
      }

      return Object.assign({}, state, {
        // clear the `item.value`
        item: {
          value: '',
        },
        // create a new array instance and push the item
        data: [
          ...(state.data),
          item,
        ],
      });
    }

    case TODO_DELETE: {
      // don't use `state.data` directly
      const { data, ...restState } = state;

      // `[...data]` means a new instance of the `data` array
      // and filter them and remove the target TODO item
      const updated = [...data].filter(_item => _item.value !== item.value);

      return Object.assign({}, restState, {
        data: updated,
      });
    }

    // do nothing
    default: {
      return state;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

And next, define reducers/index.ts which combines all reducers:

(currently only one reducer, yet)

import { combineReducers } from 'redux';
import todo, { initialState as todoState } from './todo';

export const initialState = {
  todo: todoState,
};

export default combineReducers({
  todo,
});
Enter fullscreen mode Exit fullscreen mode

Create the Store

We define the one store so that we can access any states from the store.

And pass the store to the page: with the NEXT.js, pages/_app.tsx is one of the best choices.

store.ts

import thunkMiddleware from 'redux-thunk';
import {
  createStore,
  applyMiddleware,
  compose,
  Store as ReduxStore,
} from 'redux';
import { createLogger } from 'redux-logger';
import reducers, { initialState } from './reducers';

const dev: boolean = process.env.NODE_ENV !== 'production';

export type Store = ReduxStore<typeof initialState>;

export default (state = initialState): Store => {
  const middlewares = dev ? [thunkMiddleware, createLogger()] : [];
  return createStore(reducers, state, compose(applyMiddleware(...middlewares)));
};
Enter fullscreen mode Exit fullscreen mode

pages/_app.tsx

import { NextPageContext } from 'next';
import App from 'next/app';
import withRedux from 'next-redux-wrapper';
import { Provider } from 'react-redux';
import store, { Store } from '../store';

interface AppContext extends NextPageContext {
  store: Store;
}

class MyApp extends App<AppContext> {
  render() {
    const { store, Component, ...props } = this.props;
    return (
      <Provider store={store}>
        <Component {...props} />
      </Provider>
    );
  }
}

export default withRedux(store)(MyApp);
Enter fullscreen mode Exit fullscreen mode

Compose the Pages

First, define selectors to avoid deep nested state:

import { createSelector } from 'reselect';

export const selectState = () => state => state.todo;

export const selectTodoItem = () =>
  createSelector(
    selectState(),
    todo => todo.item,
  );

export const selectTodoData = () =>
  createSelector(
    selectState(),
    todo => todo.data,
  );
Enter fullscreen mode Exit fullscreen mode

Second, use that selectors and pass them to the container with the actions:

containers/page.ts

import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
  compose,
  pure,
} from 'recompose';
import {
  onChangeTodo,
  addTodo,
  deleteTodo,
} from '../actions';
import {
  selectTodoItem,
  selectTodoData,
} from '../selectors';
import Page from '../components/page';

export default compose(
  connect(
    createSelector(
      selectTodoItem(),
      selectTodoData(),
      (item, data) => ({ item, data }),
    ),
    {
      onChangeTodo,
      addTodo,
      deleteTodo,
    },
  ),
  pure,
)(Page);
Enter fullscreen mode Exit fullscreen mode

Third, implement the page component:

components/page.tsx

import React from 'react';
import { compose } from 'recompose';
import Todo from './todo';

const Page = (props) => {
  // defined in the `containers/page.ts`, so the `props` is like this:
  //
  // const {
  //   item,
  //   data,
  //   onChangeTodo,
  //   addTodo,
  //   deleteTodo,
  // } = props;
  //
  return <Todo {...props} />;
};

export default compose()(Page);
Enter fullscreen mode Exit fullscreen mode

Implement components/todo.tsx:

import React from 'react';
import { compose } from 'recompose';

const Todo= (props) => {
  const {
    item,
    data,
    onChangeTodo,
    addTodo,
    deleteTodo,
  } = props;

  return (
    <React.Fragment>
      <h1>TODO</h1>
      <form onSubmit={(e) => {
        e.preventDefault();
        addTodo({
          value: item.value,
        });
      }}>
        <input
          type="text"
          value={item.value}
          onChange={e => onChangeTodo({
            value: e.target.value,
          })}
        />
        <br />
        <input
          type="submit"
          value="SUBMIT"
          style={{
            display: 'none',
          }}
        />
      </form>
      <hr />
      {data.map((item, index) => (
        <p key={index}>
          {item.value}
          {' '}
          <button onClick={() => deleteTodo(item)}>
            DELETE
          </button>
        </p>
      ))}
    </React.Fragment>
  );
};

export default compose()(Todo);
Enter fullscreen mode Exit fullscreen mode

Rewrite pages/index.tsx

Finally, update pages/index.tsx like this:

import {
  NextPageContext,
  NextComponentType,
} from 'next';
import { compose } from 'recompose';
import { connect } from 'react-redux';
import Page from '../containers/page';
import { addTodo } from '../actions';
import { Store } from '../store';

interface IndexPageContext extends NextPageContext {
  store: Store;
}

const IndexPage: NextComponentType<IndexPageContext> = compose()(Page);

IndexPage.getInitialProps = ({ store, req }) => {
  const isServer: boolean = !!req;

  // we can add any custom data here
  const { todo } = store.getState();
  store.dispatch(addTodo(Object.assign(todo.item, {
    value: 'Hello World!',
  })));

  return {
    isServer,
  };
}

export default connect()(IndexPage);
Enter fullscreen mode Exit fullscreen mode

An image of the TODO app

TODO_ONCHANGE:

An image of TODO_ONCHANGE

TODO_ADD:

An image of TODO_ADD

TODO_DELETE:

An image of TODO_DELETE

Conclusion

Practice makes perfect.

Thank you for your reading!

💖 💪 🙅 🚩
saltyshiomix
Shiono Yoshihide

Posted on October 12, 2019

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

Sign up to receive the latest update from our blog.

Related