Independent frontend

GoF, Pure Architecture, Perfect Code - The True Programmer's Handbooks. But in the world of front-end, many ideas from these books are not available. At least resemblance to the real world is very difficult to find. Maybe the modern frontend is ahead of time? Maybe “functional” programming and React have already proven their superiority over OOP? In this article, I want to give an example of a todo-list application that I tried to implement according to the principles and approaches described in classic books.

Framework dependency


The framework is the cornerstone of the modern front. On hh.ru vacancies are React vs Angular vs Vue developers. I worked with each of these frameworks, and for a very long time I could not understand why I had to work with Vue from 3 years to repaint a button from red to purple? Why do I need to know how to inherit on prototypes, or how the event loop works in order to move the same button from the left corner to the right? The answer is simple - we write library-bound applications.

Why do companies with long experience working with React? Yes, because the application is highly dependent on the features of this React itself, and in order not to break anything when repainting a button, you should smash your head over how change detection, rendering of the component tree works inside React, and how it relates to the task of repainting the button. (I agree, these are all special cases ... And in your company, are you ready to take a specialist without experience working with the framework?)
Program using a language, not a language. (McConnell) A
framework is a tool, not a way of life. (Martin)
For the world of the front, these theses are empty phrase at best, and at worst a challenge to prove the opposite. Let's look at the official React documentation, and see an example of a simple todo-list application.

Example todo-list from the official React website
class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { items: [], text: '' };
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  render() {
    return (
      <div>
        <h3> </h3>
        <TodoList items={this.state.items} />
        <form onSubmit={this.handleSubmit}>
          <label htmlFor="new-todo">
              ?
          </label>
          <input
            id="new-todo"
            onChange={this.handleChange}
            value={this.state.text}
          />
          <button>
             #{this.state.items.length + 1}
          </button>
        </form>
      </div>
    );
  }

  handleChange(e) {
    this.setState({ text: e.target.value });
  }

  handleSubmit(e) {
    e.preventDefault();
    if (!this.state.text.length) {
      return;
    }
    const newItem = {
      text: this.state.text,
      id: Date.now()
    };
    this.setState(state => ({
      items: state.items.concat(newItem),
      text: ''
    }));
  }
}

class TodoList extends React.Component {
  render() {
    return (
      <ul>
        {this.props.items.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    );
  }
}


“Using props and state , you can create a small to-do list application. In this example, state is used to track the current list of elements ... “

For a novice programmer (that is, me a couple of years ago), this phrase automatically generates the output:“ Here is an ideal example of a todo-list application ”. But who stores the state in the component ?! For this there is a state management library.

An example of a todo-list from the Redux documentation

Yes, that’s how the application has become much clearer and easier (no) . Can we try to draw the dependencies in the right direction?

Independent decision


Let's look at the problem of todo-list not as front-end, that is, forget that we need to draw HTML (“web is a detail”). We won’t be able to check the result with our eyes, so we’ll have to write tests (as Uncle Bob says, “you can use TDD then”). And what is the task? What is todo-list? We are trying to write.

Todo.spec.ts
import { Todo } from './Todo';

describe('Todo', () => {
  let todo: Todo;

  beforeEach(() => {
    todo = new Todo('description');
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todo.getTitle()).toBe('description');
  });

  it('+isCompleted() should returns completion flag', () => {
    expect(todo.isCompleted()).toBe(false);
  });

  it('+toggleCompletion() should invert completion flag', () => {
    todo.toggleCompletion();
    expect(todo.isCompleted()).toBe(true);
  });
});


TodoList.spec.ts
import { TodoList } from './TodoList';

describe('TodoList', () => {
  let todoList: TodoList;

  beforeEach(() => {
    todoList = new TodoList();
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todoList.getItems()).toEqual([]);
  });

  it('+add() should create item and add to collection', () => {
    todoList.add('Write tests');
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should create item with the description', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(item.getTitle()).toBe(description);
  });

  it('+getCompletedItems() should not returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    expect(todoList.getCompletedItems()).toEqual([]);
  });

  it('+getCompletedItems() should returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getCompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(todoList.getUncompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should not returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getUncompletedItems()).toEqual([]);
  });
});


export class Todo {
  private completed: boolean = false;

  constructor(private description: string) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
  }
}

import { Todo } from './Todo';

export class TodoList {
  private items: Todo[] = [];

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description));
  }
}

We get two simple classes with informative interfaces. Is that all? Tests pass. Now pick up the React.

import React from 'react';

import { TodoList } from './core/TodoList';

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getItems().map((todo) => (
          <li key={todo.getTitle()}>{todo.getTitle()}</li>
        ))}
      </ul>
    </div>
  );
};

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

And make sure that ... Adding an item does not work. Hmm ... Now it’s clear why everything needs to be written in state - so that the React component redraws after learning about the changes. But is this a reason to violate all possible principles and put logic in the view component? A little patience and courage. To solve the problem, calling forceUpdate () in an infinite loop or the Observer pattern is perfect .

I like the RxJs library, but I will not connect it, but just copy its API necessary for our task.

Observable.spec.ts
import { Observable, Subject } from './Observable';

describe('Observable', () => {
  let subject: Subject<any>;
  let observable: Observable<any>;

  beforeEach(() => {
    subject = new Subject();
    observable = subject.asObservable();
  });

  it('should call callback on next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    subject.next({});
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('should not call callback on next value if unsubscribed', async () => {
    const spy = jasmine.createSpy();
    const subscription = observable.subscribe(spy);
    subscription.unsubscribe();
    subject.next({});
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('should send to callback subject.next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    const sendingValue = {};
    subject.next(sendingValue);
    await delay();
    expect(spy.calls.first().args[0]).toBe(sendingValue);
  });
});

function delay(timeoutInMs?: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, timeoutInMs));
}


Observable.ts
export interface Observable<T = unknown> {
  subscribe(onNext: (value: T) => void): Subscription;
}

export interface Subscription {
  unsubscribe(): void;
}

export class Subject<T = unknown> implements Observable<T> {
  protected callbackSet: Set<(value: T) => void> = new Set();

  asObservable(): Observable<T> {
    return this;
  }

  subscribe(onNext: (value: T) => void): Subscription {
    this.callbackSet.add(onNext);
    return { unsubscribe: () => this.callbackSet.delete(onNext) };
  }

  next(value: T): void {
    Promise.resolve().then(() => this.callbackSet.forEach((onNext) => onNext(value)));
  }
}


In my opinion, nothing too complicated. Add a test (notification of changes - that is the logic).

  it('+TodoList.prototype.add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add('description');
    await delay();
    expect(spy).toHaveBeenCalled();
  });

Let’s think for a moment, but does a change in a Todo element affect the state of a TodoList? Affects - the getCompletedItems / getUncompletedItems methods must return a different set of elements. Maybe it's worth moving the toggleCompletion to the TodoList class? It’s a bad idea - with this approach, we’ll have to inflate TodoList for every feature regarding a new Todo-element (we will return to this later). But how to learn about the changes, again the Observer? To make things simpler, let the Todo-element itself communicate changes through a callback.

The full version of the program looks like this.

import React from 'react';
import { Observable, Subject } from 'src/utils/Observable';
import { generateId } from 'src/utils/generateId';

export class Todo {
  private completed: boolean = false;

  id: string = generateId();

  constructor(private description: string, private onCompletionToggle?: (todo: Todo) => void) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onCompletionToggle?.(this);
  }
}

export class TodoList {
  private items: Todo[] = [];
  private changesSubject = new Subject();

  readonly changes: Observable = this.changesSubject.asObservable();

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description, () => this.changesSubject.next({})));
    this.changesSubject.next({});
  }
}

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  componentDidMount(): void {
    this.todoList.changes.subscribe(() => this.forceUpdate());
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getUncompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
        {todoList.getCompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
      </ul>
    </div>
  );
};

export const TodoCmp: React.FC<{ todo: Todo }> = ({ todo }) => (
  <li
    style={{ textDecoration: todo.isCompleted() ? 'line-through' : '' }}
    onClick={() => todo.toggleCompletion()}
  >
    {todo.getTitle()}
  </li>
);

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

This seems to be what the todo-list application independent of the framework should look like. The only limitation is PL. You can implement the display for the console, or use Angular.

Perhaps the current version of the application is not complex enough to make sure that an independent approach is working and to demonstrate its strengths. Therefore, we’ll connect our imagination to simulate a more or less plausible todo-list development scenario.

Edits from the customer


The main nightmare of most projects is changing requirements. You know that edits cannot be avoided, and you know that this is normal. But how do you prepare for future changes?

Special Todo Elements


One of the key features of OOP is the ability to solve the problem through the introduction of new types. Perhaps this is the most powerful OOP technique that can single-handedly pull out a complex and cumbersome program. For example, I do not know what is required of the Todo element. It may be necessary to be able to change its name, it may be necessary to add additional attributes, it may be possible to change this element through direct access to the SpaceX server ... But I'm sure that the requirements will change, and I will need different types of Todo.

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }
}

It seems that to display a special type, we will need to change the view components as well. In practice, I met (and wrote) components in which a million different conditions turn a div block from a giraffe into a machine gun. To avoid this problem, you can create a hoc component with a huge switch-case list. Or apply the Visitor pattern and dual dispatch, and let the Todo element decide for itself what type of component to draw.

export class Todo {
  id: string = '';

  constructor(
    protected title: string,
    private completed: boolean = false,
    protected onChange?: (todo: Todo) => void,
  ) {}

  getTitle(): string {
    return this.title;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderSimpleTodo(this);
  }
}

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderEditableTodo(this);
  }
}

export class TodoRenderer {
  renderSimpleTodo(todo: Todo): any {
    return <SimpleTodoCmp todo={todo}></SimpleTodoCmp>;
  }

  renderFixedTodo(todo: Todo): any {
    return <FixedTodoCmp todo={todo}></FixedTodoCmp>;
  }

  renderEditableTodo(todo: EditableTodo): any {
    return <EditableTodoCmp todo={todo}></EditableTodoCmp>;
  }
}

The dual dispatch option is especially useful when one type of item has different views. You can change them by substituting different TodoRenderers into the render method.

Now we are ready. Fear of new demands for “special” Todo elements has disappeared. I think the developers themselves could take the initiative, and offer a couple of features that require the introduction of new types, which are now added by writing new code and minimal change to the existing one.

Saving data to the server


What kind of application is without interacting with the server? Of course, you need to be able to save our list via HTTP - another new requirement. We are trying to solve the problem.

AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay(1);
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should save todoList state on success and rollback to it on error', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay(1);
    const savedData = JSON.stringify(todoList.getItems());
    api.save = () => Promise.reject('Mock saving failed');
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay(1);
    expect(JSON.stringify(todoList.getItems())).toBe(savedData);
  });
});


export interface TodoListApi {
  getItems(): Promise<TodoParams[]>;
  save(todoParamsList: TodoParams[]): Promise<void>;
}

export class AppTodoList implements TodoList {
  private todoFactory = new TodoFactory();
  private changesSubject = new Subject();

  changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private subscription: Subscription = this.state.changes.subscribe(() => this.onStateChanges());
  private synchronizedTodoParamsList: TodoParams[] = [];

  constructor(private api: TodoListApi) {}

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.updateState(todoParamsList);
  }

  private updateState(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.subscription.unsubscribe();
    this.subscription = todoList.changes.subscribe(() => this.onStateChanges());
    this.synchronizedTodoParamsList = todoParamsList;
    this.changesSubject.next({});
  }

  private async onStateChanges(): Promise<void> {
    this.changesSubject.next({});
    try {
      const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
      await this.api.save(params);
      this.synchronizedTodoParamsList = params;
    } catch {
      this.updateState(this.synchronizedTodoParamsList);
    }
  }

  destroy(): void {
    this.subscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

We do not know how the application should behave. Wait for the save to succeed and display the changes? Allow changes to be displayed, and in case of error, roll back to synchronized state? Or ignore save errors altogether? Most likely, the customer also does not know this. Therefore, changes to requirements are inevitable, but they should affect only one class responsible for conservation. And on the way the next edit.

Change history


“We need the ability to cancel / redo actions” ...

It seems that a wave of new edits takes us by surprise. But in no case, you can not sacrifice tests. Now it is completely unclear which inheritance hierarchy is better suited, and whether inheritance is generally suitable. Therefore, nothing bad will happen if we simply supplement our dirty class (we will consider this the implementation details), sacrificing the principle of sole responsibility.

TodoListHistory.spec.ts
import { TodoParams } from 'src/core/TodoFactory';
import { delay } from 'src/utils/delay';
import { TodoListHistory } from './TodoListHistory';

describe('TodoListHistory', () => {
  let history: TodoListHistory;

  beforeEach(() => {
    history = new TodoListHistory();
  });

  it('+getState() should returns TodoParams[]', () => {
    expect(history.getState()).toEqual([]);
  });

  it('+setState() should rewrite current state', () => {
    const newState = [{ title: '' }] as TodoParams[];
    history.setState(newState);
    expect(history.getState()).toBe(newState);
  });

  it('+hasPrev() should returns false on init', () => {
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasPrev() should returns true after setState()', () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
  });

  it('+switchToPrev() should switch on prev state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasPrev() should returns false after switch to first', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasNext() should returns false on init', () => {
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns true after switchToPrev()', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasNext()).toBe(true);
  });

  it('+switchToNext() should switch on next state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState([]);
    history.setState(prevState);
    history.switchToPrev();
    history.switchToNext();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasNext() should returns false after switchToNext()', () => {
    history.setState([]);
    history.switchToPrev();
    history.switchToNext();
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns false after setState()', () => {
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    expect(history.hasNext()).toBe(false);
  });

  it('+switchToPrev() should switch on prev state after setState()', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+setState() should not emit changes', async () => {
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.setState([]);
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToPrev();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    history.switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToNext();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+reset() should reset history and apply initial state', async () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
    const initState = [{ title: '' }] as TodoParams[];
    history.reset(initState);
    expect(history.hasPrev()).toBe(false);
    expect(history.getState()).toBe(initState);
  });
});


AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should ignore error on save', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay();
    api.save = jasmine.createSpy().and.returnValue(Promise.reject('Mock saving failed'));
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay();
    expect(api.save).toHaveBeenCalled();
    expect(todoList.getItems()).toHaveLength(2);
  });

  it('+resolve() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    await todoList.resolve();
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+add() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+history.switchToPrev() should change todoList state on prev', async () => {
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(0);
    expect(todoList.getItems()).toHaveLength(0);
  });

  it('+history.switchToPrev() should change todoList state on prev after resolve()', async () => {
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(2);
    expect(todoList.getHistory().getState()).toHaveLength(2);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(1);
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should emit changes after history.switchToPrev()', async () => {
    todoList.add({ title: '' });
    todoList.getHistory().switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });
});


import { TodoParams } from 'src/core/TodoFactory';
import { Observable, Subject } from 'src/utils/Observable';

export class TodoListHistory {
  private changesSubject = new Subject();
  private history: TodoParams[][] = [this.state];

  changes: Observable = this.changesSubject.asObservable();

  constructor(private state: TodoParams[] = []) {}

  reset(state: TodoParams[]): void {
    this.state = state;
    this.history = [this.state];
  }

  getState(): TodoParams[] {
    return this.state;
  }

  setState(state: TodoParams[]): void {
    this.deleteHistoryAfterCurrentState();
    this.state = state;
    this.history.push(state);
  }

  private nextState(state: TodoParams[]): void {
    this.state = state;
    this.changesSubject.next({});
  }

  private deleteHistoryAfterCurrentState(): void {
    this.history = this.history.slice(0, this.getCurrentStateIndex() + 1);
  }

  hasPrev(): boolean {
    return this.getCurrentStateIndex() > 0;
  }

  hasNext(): boolean {
    return this.getCurrentStateIndex() < this.history.length - 1;
  }

  switchToPrev(): void {
    const prevStateIndex = Math.max(this.getCurrentStateIndex() - 1, 0);
    this.nextState(this.history[prevStateIndex]);
  }

  switchToNext(): void {
    const nextStateIndex = Math.min(this.getCurrentStateIndex() + 1, this.history.length - 1);
    this.nextState(this.history[nextStateIndex]);
  }

  private getCurrentStateIndex(): number {
    return this.history.indexOf(this.state);
  }
}

import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { Todo } from '../core/Todo';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { TodoListApi } from './TodoListApi';
import { HistoryControl, TodoListHistory } from './TodoListHistory';

export class AppTodoList implements TodoList {
  private readonly todoFactory = new TodoFactory();
  private readonly history: TodoListHistory = new TodoListHistory();

  private changesSubject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanges(),
  );
  private historySubscription = this.history.changes.subscribe(() => this.onHistoryChanges());

  constructor(private api: TodoListApi) {}

  private onStateChanges(): void {
    const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
    this.history.setState(params);
    this.api.save(params).catch(() => {});
    this.changesSubject.next({});
  }

  private onHistoryChanges(): void {
    const params = this.history.getState();
    this.updateStateTodoList(params);
    this.api.save(params).catch(() => {});
  }

  private updateStateTodoList(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() => this.onStateChanges());
    this.changesSubject.next({});
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateStateTodoList(todoParamsList);
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
    this.historySubscription.unsubscribe();
  }

  getHistory(): HistoryControl<TodoParams[]> {
    return this.history;
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

Since the change history is pretty much the same, let’s separate the management of the todo-list history into the base class.

import { Todo } from 'src/core/Todo';
import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { HistoryControl, HistoryState } from './HistoryState';

export class HistoricalTodoList implements TodoList, HistoryControl {
  protected readonly todoFactory = new TodoFactory();
  protected readonly history = new HistoryState<TodoParams[]>([]);

  private changesSubject: Subject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanged(this.getSerializedState()),
  );

  constructor() {}

  protected onStateChanged(params: TodoParams[]): void {
    this.history.addState(params);
    this.changesSubject.next({});
  }

  protected onHistorySwitched(): void {
    this.updateState(this.history.getState());
  }

  protected updateState(todoParamsList: TodoParams[]): void {
    this.state = new TodoListImp(todoParamsList);
    this.updateStateSubscription();
    this.changesSubject.next({});
  }

  private updateStateSubscription(): void {
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() =>
      this.onStateChanged(this.getSerializedState()),
    );
  }

  private getSerializedState(): TodoParams[] {
    return this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }

  canUndo(): boolean {
    return this.history.hasPrev();
  }

  canRedo(): boolean {
    return this.history.hasNext();
  }

  undo(): void {
    this.history.switchToPrev();
    this.onHistorySwitched();
  }

  redo(): void {
    this.history.switchToNext();
    this.onHistorySwitched();
  }
}

import { TodoParams } from 'src/core/TodoFactory';
import { HistoricalTodoList } from './HistoricalTodoList';
import { TodoListApi } from './TodoListApi';

export class ResolvableTodoList extends HistoricalTodoList {
  constructor(private api: TodoListApi) {
    super();
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateState(todoParamsList);
  }

  protected onStateChanged(params: TodoParams[]): void {
    super.onStateChanged(params);
    this.api.save(params).catch(() => this.undo());
  }

  protected onHistorySwitched(): void {
    super.onHistorySwitched();
    this.api.save(this.history.getState()).catch(() => {});
  }
}

The solution to the conservation problem came by itself. Now we can not worry about what conservation strategy the customer will choose in the long run. You can provide him with all 3 options to choose from, expanding the base class.

Summary


It seems that from our todo-list we've got a small prototype of Google Keep. Link to the repository to launch the application or go over the history of commits.

What fundamentally differs this example from most front-end applications? We did not depend on libraries, therefore, a person who has never worked with React can understand this application. Our decisions were aimed only at obtaining the result, without distractions on the details of the framework, so the code more or less reflects the problem being solved. We managed to make it easy to add new types of Todo elements, and we are ready to change the conservation strategy.

What difficulties did we encounter? We solved the problem of updating the view using the Observer pattern without reference to the framework. As it turned out, the application of this pattern was still required to solve the main problem (even if we did not need to draw HTML). Therefore, we did not incur costs by abandoning the “services” of the change detection system built into the framework.

I would like to emphasize that writing tests was not any difficulty. Testing simple independent objects with an informative interface is a pleasure. The complexity of the code depended only on the task itself and my skills (or curvature).

What about the level of developer who would handle this? Could Junior React Developer write such a solution? “Programming is more like a craft”, so without the practice of using OOP and patterns, I think it would be difficult. But you and your company decide what you invest in. Do you practice OOP or understand the intricacies of the next framework? I only again became convinced of the relevance of the literary works of experienced programmers, and showed how to start using the advice of the classics at full capacity at the front.

Thanks for reading!

All Articles