loading...
Cover image for Learn the Redux Architecture by Creating the Minimal TODO App on top of NEXT.js

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

saltyshiomix profile image Shiono Yoshihide ・5 min read

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`)

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

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

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

Step 1: Hello World

$ mkdir test-app
$ cd test-app

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

package.json

{
  "name": "test-app",
  "scripts": {
    "dev": "next"
  }
}

pages/index.tsx

export default () => <p>Hello World</p>;

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

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';

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 });

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;
    }
  }
};

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,
});

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)));
};

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);

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,
  );

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);

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);

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);

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);

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!

Posted on by:

Discussion

markdown guide
 

Hi, Nice article. Thank you.

I would like to use redux dev tools with this in development, but I am finding it difficult to setup.
Here is the link to the tool documentation. github.com/zalmoxisus/redux-devtoo...

Can you help take a look?

 

Thanks, I have figured this out.

What I did!

  • Added the 'redux-devtools-extension' package (yarn add redux-devtools-extension)
  • Import the composeWithDevTools (import { composeWithDevTools } from 'redux-devtools-extension';)
  • Create a composeEnhancers (const composeEnhancers = composeWithDevTools({});)
  • wrap the applyMiddleware function with the composeEnhancers Function (composeEnhancers(applyMiddleware(...middlewares)))

Update Store.ts file

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

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

export type Store = ReduxStore<typeof initialState>;

const composeEnhancers = composeWithDevTools({});

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

Yes, you have done right!

Or just replace compose with composeWithDevtools like this commit:

const { composeWithDevTools } = dev ? require('redux-devtools-extension') : require('redux-devtools-extension/logOnlyInProduction');

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

Thanks, Do you have an idea of how to include SCSS to this setup?

 

Heard nextjs and redux: will try later tonight. Sounds like a lot of fun! Thanks for posting this!! ^^

 

Thank you for your comment!

If you have any advices, feel free to contact me :)