Example App: Clean Architecture with React, Overmind and Local Storage, pt. 1

sutt0n

Joseph Sutton

Posted on January 30, 2022

Example App: Clean Architecture with React, Overmind and Local Storage, pt. 1

Disclaimer: This article may seem to bounce around like a squirrel in an acorn field before the winter.

TL;DR: GitHub repository.

So, clean architecture! I'm a huge proponent of it. It's a great way to ensure that your testability of your project is so easy, a monkey could do it. What is clean architecture? It's something that pushes us to completely separate the business logic from the tech stack by allowing us to define clear boundaries by using dependency injection (we'll do this via an applicationContext):

Clean Architecture Diagram

I'm not going to go into detail, because clean architecture is a principle best explained by others; for example, this summary on Gist. Who founded this concept? Uncle Bob Martin. You can check out his Twitter!

How

How are we going to implement this separation? We'll be implementing everything in a monorepo via Lerna. I was going to utilize Terraform with this, but decided that was borderline over-engineering something simple like an Example Application. Maybe in the future!

Structure

How's the packaging going to look? What about the file structure? First off, we'll need a view, ui - that'll be our frontend in which I used create-react-app with a custom template I created.

Secondly, we'll need a place for our business logic, business. This will hold our entities, use-cases, and such. Thirdly, we'll need a place for storage methods, persistence. This is where methods for local storage will live.

Here's what our structure looks like so far:

  • packages/ui
  • packages/business
  • packages/persistence

The View

Let's dive in. So, I stated that I have a create-react-app template. This is essentially my frontend boilerplate for clean architecture that I've made - it's just for frontend and local storage. For TypeScript aficionados, I will be making one shortly after this article. The template has everything wrapped around the frontend, including local storage for persistence; however, I moved things around for this article.

Overmind

I used (https://overmindjs.org/)[Overmind] for state management. It's a more declarative state management system, and allows you to be as complex as you want. It's heavily aimed at allowing the developer to focus on the testability and readability of his/her application.

I'll be writing an article on Overmind as well. 😁

Code

Okay, we're actually diving in now. I promise.

First off, we have our plain ole' index.js which pulls in Overmind to the UI:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { createOvermind } from 'overmind';
import { Provider } from 'overmind-react';
import { config } from './presenter/presenter';
import App from './App.jsx';

const overmind = createOvermind(config);

ReactDOM.render(
  <Provider value={overmind}>
    <App />
  </Provider>,
  document.getElementById('root'),
);
Enter fullscreen mode Exit fullscreen mode

That's easy enough. I won't post App.jsx, but it's just going to just reference the Todos component in views/Todos.jsx:

import * as React from 'react';
import { useActions, useState } from '../presenter/presenter';

const Todo = ({ todo }) => {
  useState();
  return (
    <li>
      {todo.title} {todo.description}
    </li>
  );
};

export const Todos = () => {
  const state = useState();
  const {
    addTodoItemAction,
    updateTodoTitleAction,
    updateTodoDescriptionAction,
  } = useActions();

  return (
    <>
      <input
        type="text"
        name="title"
        placeholder="Title"
        onChange={e => updateTodoTitleAction(e.target.value)}
      />
      <input
        type="textarea"
        name="description"
        placeholder="Description"
        onChange={e => updateTodoDescriptionAction(e.target.value)}
      />
      <button onClick={addTodoItemAction}>Add</button>
      <ul>
        {state.todos.map(todo => (
          <Todo key={todo.id} todo={todo} />
        ))}
      </ul>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Since we're diving into Overmind territory, I'll explain some things we have going on here: we have two hooks, useActions and useState which pulls in our current state of the application and Overmind actions. Actions are essentially where state reads and mutations happen, and it's where we inject our applicationContext. I've named the directory where Overmind lives as presenter, because that's where our presentation logic will live.

Let's look at that file, ui/presenter/presenter.js:

import {
  createStateHook,
  createActionsHook,
  createEffectsHook,
  createReactionHook,
} from "overmind-react";
import { state } from "./state";
import { applicationContext } from '../applicationContext';
import { addTodoItemAction } from './actions/addTodoItemAction';
import { updateTodoTitleAction } from './actions/updateTodoTitleAction';
import { updateTodoDescriptionAction } from './actions/updateTodoDescriptionAction';
import { deleteTodoItemAction } from './actions/deleteTodoItemAction';

const actions = {
  addTodoItemAction,
  updateTodoTitleAction,
  updateTodoDescriptionAction,
  deleteTodoItemAction,
};

export const config = {
  state,
  actions,
  effects: applicationContext,
};

export const useState = createStateHook();
export const useActions = createActionsHook();
export const useEffects = createEffectsHook();
export const useReaction = createReactionHook();
Enter fullscreen mode Exit fullscreen mode

After gandering at that, you're probably anxious to see what an action looks like with an applicationContext. Before I show y'all applicationContext, let's gander at the presenter/actions/addTodoItemAction.js:

export const addTodoItemAction = ({ state, effects: { ...applicationContext }}) => {
  const { todoTitle: title, todoDescription: description } = state;

  const todos = applicationContext.getUseCases().addTodoItemInteractor({
    applicationContext,
    title,
    description,
  });

  state.todos = todos;
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple (it gets simpler for those that whom are confused, I promise), really. We grab our use cases from applicationContext. You may be asking, "Why not just include the interactor? Why go through that? Well, let's look at the unit test:

const { createOvermindMock } = require("overmind");
const { config } = require("../presenter");

describe("addTodoItemAction", () => {
  let overmind;
  let addTodoItemInteractorStub;
  let mockTodo = { title: "TODO Title", description: "TODO Description" };

  beforeEach(() => {
    addTodoItemInteractorStub = jest.fn().mockReturnValue([mockTodo]);

    // TODO: refactor
    overmind = createOvermindMock(
      {
        ...config,
        state: { todoTitle: "TODO Title", todoDescription: "TODO Description" },
      },
      {
        getUseCases: () => ({
          addTodoItemInteractor: addTodoItemInteractorStub,
        }),
      }
    );
  });

  it("calls the interactor to add a todo item", async () => {
    await overmind.actions.addTodoItemAction();

    expect(addTodoItemInteractorStub).toHaveBeenCalled();
    expect(addTodoItemInteractorStub).toHaveBeenCalledWith({
      applicationContext: expect.anything(),
      ...mockTodo,
    });
    expect(overmind.state).toEqual(
      expect.objectContaining({
        todos: [mockTodo],
      })
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

I'd much rather mock out applicationContext than use jest.mock for each test. Having a context that unit tests can share for a potentially large codebase will save us a lot of time in writing these tests out. Another reason I believe it's better is for designing/defining your logic via Test Driven Development.

Business

Well, we've covered the actions that call our use-cases, or interactors. Let's dive into our business logic by first taking a look at the interactor being called from our action above, packages/business/useCases/addTodoItemInteractor.js:

import { Todo } from '../entities/Todo';

/**
 * use-case for adding a todo item to persistence
 *
 * @param {object} provider provider object
 */
export const addTodoItemInteractor = ({ applicationContext, title, description }) => {
  const todo = new Todo({ title, description }).validate().toRawObject();

  const todos = [];
  const currentTodos = applicationContext.getPersistence().getItem({
    key: 'todos',
    defaultValue: [],
  });

  if (currentTodos) {
    todos.push(...currentTodos);
  }

  todos.push(todo);

  applicationContext.getPersistence().setItem({ key: 'todos', value: todos });

  return todos;
};
Enter fullscreen mode Exit fullscreen mode

Do you see where we're going with this? This interactor is the use-case surrounding the entity, Todo in the diagram above. It calls two persistence methods, which are just essentially local storage wrappers I've created. Let's take a gander at the unit test for this interactor:

const { addTodoItemInteractor } = require("./addTodoItemInteractor");

describe("addTodoItemInteractor", () => {
  let applicationContext;
  let getItemStub;
  let setItemStub;

  beforeAll(() => {
    getItemStub = jest.fn().mockReturnValue([]);
    setItemStub = jest.fn();

    applicationContext = {
      getPersistence: () => ({
        getItem: getItemStub,
        setItem: setItemStub,
      }),
    };
  });

  it("add a todo item into persistence", () => {
    const result = addTodoItemInteractor({
      applicationContext,
      title: "TODO Title",
      description: "TODO Description",
    });

    expect(getItemStub).toHaveBeenCalled();
    expect(getItemStub).toHaveBeenCalledWith({
      key: "todos",
      defaultValue: [],
    });
    expect(setItemStub).toHaveBeenCalled();
    expect(setItemStub).toHaveBeenCalledWith({
      key: "todos",
      value: [
        {
          title: "TODO Title",
          description: "TODO Description",
        },
      ],
    });
    expect(result).toEqual([
      {
        title: "TODO Title",
        description: "TODO Description",
      },
    ]);
  });
});
Enter fullscreen mode Exit fullscreen mode

Easy, breezy, beautiful. Everything can be stubbed, mocked out, etc. All we care about is the primitive logic within the interactor itself - not what's in local storage, not what persistence we're using whether it's local storage or a remote/local database, and we don't care about the UI or any of the Overmind logic.

All we care about is the business logic. That's all we're testing here, and that's all we care about testing here. Let's take a look at those persistence methods, setItem and getItem.

Persistence

The two methods called above are setItem and getItem. Pretty straight-forward. Honestly, I probably didn't have to wrap them; however, I wanted to show that for persistence to be able to be easily interchangeable no matter what we use, practically nothing has to change inside the interactor.

Let's look at setItem:

module.exports.setItem = ({ key, value }) =>
  localStorage.setItem(key, JSON.stringify(value));
Enter fullscreen mode Exit fullscreen mode

Easy enough. The unit test:

const { setItem } = require('./setItem');

describe('setItem', () => {
  let setItemStub;
  global.localStorage = {};

  beforeEach(() => {
    setItemStub = jest.fn();

    global.localStorage.setItem = setItemStub;
  });

  it('sets the item given the key/value pair', () => {
    setItem({ key: 'todos', value: 'todos value' });

    expect(setItemStub).toHaveBeenCalled();
    expect(setItemStub).toHaveBeenCalledWith('todos', JSON.stringify('todos value'));
  });
});
Enter fullscreen mode Exit fullscreen mode

Simple enough, right? There's a pattern to unit tests and I'm sure with some ideas, one could find a way to reduce boilerplate... or just make a macro since most everything repeated is essential to its respective unit test.

Note: The only reason why we're stringifying with JSON is we're allowing the storage of objects/arrays (if you noticed in the action, the todos are an array).

That's obviously not everything. I didn't want to dive too deep into the specifics. My next article will include us hooking this same setup with a backend (more than likely serverless). What database should we use? DynamoDB or a relational database like PostgreSQL? Maybe both?

Thanks for reading! If I typed something wrong or if you have any questions, comments, concerns, or suggestions then please post them in a comment! Y'all take care now.

💖 💪 🙅 🚩
sutt0n
Joseph Sutton

Posted on January 30, 2022

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

Sign up to receive the latest update from our blog.

Related