# prostore

Prostore - это библиотека для работы с данными во фронтенде,
вдохновленная [apollo-client](https://github.com/apollographql/apollo-client).
Apollo Client решает только задачи работы с API-запросами GraphQL,
в то время как Prostore позиционируется как общее расширяемое решение,
позволяющее также работать с чисто фронтендовыми данными.
В этом плане Prostore чем-то похож на [mobx](https://mobx.js.org/README.html).

Prostore родился из необходимости быстрого решения частых задач
в SPA проектах, написанных на React с использованием React Hooks,
поэтому на данный момент код библиотеки ориентирован именно
на работу с React.

Эти задачи включают в себя:

1. Создание цепочек зависимостей по данным

2. Запрос данных с бекенда с автоматическим обновлением компонента
   при их получении и обработкой индикатора загрузки и ошибок

3. Отправка данных на бекенд в виде императивных мутирующих операций,
   с отслеживанием состояния запроса

4. Синхронизация данных с localStorage

## Установка

```
yarn add @proscom/prostore rxjs
//
npm install --save @proscom/prostore rxjs
```

## Сторы

Данные в Prostore хранятся в сторах. Каждый стор представляет собой
некое хранилище, выполняющее какую-то одну функцию. Например,
стором может быть состояние выполнения API-запроса, или данные
о текущем пользователе.

В основе Prostore лежит [RxJS](https://github.com/ReactiveX/rxjs) -
мощная библиотека для работы с Observable.
Observable это как EventEmitter, только с одним типом событий - обновление данных.
Но благодаря функциям-операторам из rxjs можно создавать цепочки
зависимостей одних Observable от других.
Поближе познакомиться с rxjs можно [здесь](http://learnrxjs.io/).

Каждый стор в Prostore реализует следующий интерфейс:

```typescript
export interface IStore<State> {
  readonly state: State;
  readonly state$: Observable<State>;
}
```

Это позволяет создавать из сторов цепочки зависимостей. Например,
при обновлении данных о текущем пользователе можно автоматически
перевыполнить API-запрос, зависящий от них.
Также в любой момент можно получить актуальное состояние стора.

### ValueStore

Это самый простой стор, который есть в `prostore`.
Он представляет собой простую обертку над `BehaviorSubject`.

```typescript
// Создание стора со значением по-умолчанию
const store = new ValueStore(5);

// Подписка на изменение значения
store.state$.subscribe((value) => {
  console.log('changed', value);
});

// Изменение значения
store.setState(6);
```

Класс `ValueStore` можно расширить, добавив в него дополнительные методы:

```typescript
class IncrementStore extends ValueStore {
  constructor() {
    super(0);
  }

  increment() {
    this.setState(this.state + 1);
  }

  decrement() {
    this.setState(this.state - 1);
  }
}
```

### BehaviorStore

Для удобства есть базовый класс [`BehaviorStore`](./src/BehaviorStore.ts)
у которого `state$` представляет собой `BehaviorSubject` из rxjs.
Расширив этот класс, можно создавать свои собственные сторы, у которых
работа с состоянием похожа на классовые компоненты в React. Например,

```typescript
import { BehaviorStore } from '@proscom/prostore';

class UserStore extends BehaviorStore {
  constructor() {
    // в конструкторе передаем начальное состояние
    super({
      user: null
    });
  }

  updateUser(newUser) {
    this.setState({ user: newUser });
  }
}
```

При расширении BehaviorStore в конструктор базового класса надо
передать первоначальное состояние стора - любой JS объект.

В отличие от `ValueStore`, `BehaviorStore` содержит дополнительную логику объединения ключей
и частичного обновления состояния (как в классовых React-компонентах).
Поэтому состоянием стора должен быть именно объект. Состояние не может быть массивом или простым типом.
Поэтому если надо использован не-объект, то оберните его в объект, присвоив
какому-нибудь ключу, либо используйте `ValueStore`:

```typescript
super({
  data: [1, 2, 3]
});
```

В любом месте этого класса (а также снаружи, но это не рекомендуется)
можно вызывать функцию `this.setState`, которая принимает обновление
состояния, либо функцию обновления состояния (как в реакте).

При вызове `this.setState` происходит одноуровневое слияние старого
состояния с новым (типа `newState = {...oldState, ...changes}`).

Если же передана функция, то она сразу вызывается и в аргумент ей
передается текущее состояние, а вернуть она должна изменения.

Если нужно сбросить состояние целиком, например, чтобы удалить какие-то ключи,
можно воспользоваться более низкоуровневым вызовом `this.state$.next(newState)`.

Создав такой стор, можно дальше подписаться на него стандартными
средствами rxjs:

```typescript
const userStore = new UserStore();

const subscription = userStore.state$.subscribe((state) => {
  console.log('state changed', state);
});

userStore.updateUser('Tester');

subscription.unsubscribe();

// Выведет:
// state changed { user: null }
// state changed { user: 'Tester' }
```

[https://codesandbox.io/s/prostore-example-behavior-jomsr](https://codesandbox.io/s/prostore-example-behavior-jomsr)

Для удобства работы с подписками в React, смотри библиотеку
[`prostore-react`](https://gitlab.com/proscom/prostore/-/tree/master/packages/prostore-react).

### AsyncBehaviorStore

Иногда может быть полезно не применять изменения состояния стора
сразу, а отложить их до следующего цикла. Так например делает React
при работе с классовыми компонентами.

В Prostore есть класс [AsyncBehaviorStore](./src/AsyncBehaviorStore.ts), который собирает все вызовы
`this.setState` в текущем синхронном цикле и выполняет их все сразу
последовательно в следующем (с помощью `setTimeout`).

Это может быть полезно, если стор может измениться более одного раза за
синхронный цикл. С точки зрения подписчиков, AsyncBehaviorStore изменится
только один раз, в то время как BehaviorStore вызовет своих подписчиков
при каждом изменении.

```typescript
import { AsyncBehaviorStore } from '@proscom/prostore';

class AsyncUserStore extends AsyncBehaviorStore {
  constructor() {
    super({
      firstName: null,
      lastName: null
    });
  }

  setFirstName(firstName) {
    this.setState({ firstName });
  }

  setLastName(lastName) {
    this.setState({ lastName });
  }
}

// ...

const userStore = new AsyncUserStore();

userStore.state$.subscribe((state) => {
  console.log('state changed', state);
});

userStore.setFirstName('first');
userStore.setLastName('last');

// Выведет:
// state changed: { firstName: null, lastName: null }
// state changed: { firstName: 'first', lastName: 'last' }
```

[https://codesandbox.io/s/prostore-example-async-kwnns](https://codesandbox.io/s/prostore-example-async-kwnns)

### RequestStore

[RequestStore](./src/RequestStore.ts) это более высокоуровневая абстракция, которая представляет
собой состояние какого-либо запроса. Запрос - это произвольная функция,
возможно асинхронная, которая превращает свои параметры `variables`
в результат `data`. Например, эта функция может выполнять GET HTTP-запрос
с помощью fetch, передавая `variables` как query-параметры, и сохранять
тело результата как `data`.

Состояние RequestStore имеет следующий тип:

```typescript
export interface IRequestState<Vars, Data> {
  data: Data | null;
  loading: boolean;
  loaded: boolean;
  error: any;
  variables: Vars | null;
}
```

У RequestStore есть основной метод, который можно вызывать снаружи:

```typescript
async function loadData(
  variables: Vars,
  options: any = {}
): Promise<IRequestState<Vars, Data>> {}
```

При вызове этой функции запускается выполнение нового запроса данных
с новыми variables. Функция завершается, когда запрос будет выполнен
успешно либо с ошибкой.

Для создания собственного стора надо расширить класс `RequestStore`,
переопределив функцию

```typescript
export type IObservableData<Data> = Promise<Data> | Observable<Data>;
function performRequest(
  variables: Vars,
  options: Options
): IObservableData<Data>;
```

Пример можно посмотреть на CodeSandbox:
[https://codesandbox.io/s/prostore-example-request-h9641](https://codesandbox.io/s/prostore-example-request-h9641)

В библиотеках `prostore-apollo` и `prostore-axios` доступны свои классы,
расширяющие RequestStore, реализующие GraphQL-запросы и обычные HTTP-запросы
соответственно.

При вызове конструктора `RequestStore` необходимо передать аргумент типа:

```typescript
export interface IRequestStoreParams<Vars, Data> {
  // Первоначальное значение data
  initialData?: Data;

  // Функция, позволяющая пропустить вызов performRequest
  // Эта функция должна вернуть undefined, если запрос не надо пропускать
  // Любое другое возвращенное значение будет сохранено как data
  skipQuery?: ISkipQueryFn<Vars, Data>;

  // Функция, которая позволяет не просто перезаписать data,
  // а объединить старую data с новой.
  // Это может быть полезно для запросов с пагинацией
  updateData?: IUpdateDataFn<Vars, Data>;

  // Идентификатор этого стора для передачи данных с сервера
  // при использовании Server-Side-Rendering
  ssrId?: string;
}

export type ISkipQueryFn<Vars, Data> = (vars: Vars) => Data | null | undefined;

export type IUpdateDataFn<Vars, Data> = (
  data: Data,
  oldData: Data,
  params: { store: any; variables: Vars; options: any }
) => Data;
```

Для удобства в качестве `skipQuery` можно передать одну из двух
предопределенных функций:

```typescript
import { skipIf, skipIfNull } from '@proscom/prostore';

new MyRequestStore({
  // Если vars не удовлетворяет функции condition, то
  // запрос не вызывается и data = defaultData
  skipQuery: skipIf((vars) => !vars, defaultData),

  // или

  // Если vars равно null,
  // то запрос не вызывается и data = defaultData
  skipQuery: skipIfNull(defaultData)
});
```

## Утилиты

### SubscriptionManager

SubscriptionManager хранит список подписок и позволяет отменить все активные подписки вызовом одной функции.
Рекомендуется использовать его при взаимодействии сторов друг с другом. См. пример ниже в разделе
[Общение между сторами](#общение-между-сторами).

## Рецепты использования

### Общение между сторами

При работе с глобальными сторами рекомендуемый способ организации взаимодействия между двумя сторами - передать один
из сторов в качестве аргумента в конструктор другого:

```typescript
import { BehaviorStore, SubscriptionManager } from '@proscom/prostore';

interface IUserStoreArgs {
  tokenStore: TokenStore;
}

interface IUserStoreState {
  user: User;
}

class UserStore extends BehaviorStore<IUserStoreState> {
  sub = new SubscriptionManager();

  constructor({ tokenStore }: IUserStoreArgs) {
    super({ user: null });
    // Подписка на другой стор в конструкторе
    this.sub.subscribe(tokenStore.state$, this.handleTokenStoreChange);
  }

  // Функция-деструктор на случай, если стор будет использован не глобально и потребуется сброс подписки
  destroy() {
    this.sub.destroy();
  }

  handleTokenStoreChange = (tokenState) => {
    // Здесь можно использовать состояние другого стора, чтобы актуализировать состояние этого стора
    this.setState({ user: tokenState.user });
  };
}

const tokenStore = new TokenStore();
const userStore = new UserStore({ tokenStore });
```

Рекомендуется избегать циклической зависимости между сторами, но если она необходима, то можно передавать один
стор в другой динамически:

```typescript
import { BehaviorStore, SubscriptionManager } from '@proscom/prostore';

class UserStore extends BehaviorStore<IUserStoreState> {
  sub = new SubscriptionManager();

  constructor() {
    super({ user: null });
  }

  setTokenStore(tokenStore: TokenStore) {
    this.sub.subscribe(tokenStore.state$, this.handleTokenStoreChange);
  }

  // destroy и handleTokenStoreChange аналогичны примеру выше
}

const userStore = new UserStore();
const tokenStore = new TokenStore({ userStore });
userStore.setTokenStore(tokenStore);
```

### События сторов

Можно использовать стор, как эмиттер произвольных событий.
Например, это может быть полезно при реализации фронтенда на компонентном фреймворке, когда нужно передать событие клика из одного компонента в другой, если они не связаны прямой иерархией (один не является родителем другого).
Такое событие нельзя надежно передать через подписку на состояние, так как состояние может быть изменено также другими событиями.
При подписке на состояние нельзя достоверно определить, каким событием это состояние было изменено, поэтому надо подписываться на само событие.

Например, пусть стор хранит число, которое можно увеличить или уменьшить. Уменьшение и увеличение значения стора - это событие.
Если мы хотим, чтобы действие выполнялось только при увеличении значения, то у нас есть два варианта:

1. Подписаться на состояние и сравнивать его с предыдущим, определяя, увеличилось оно, или нет. Такая фильтрация изменений состояния с целью определения события возможна в этом конкретном случае, но не в произвольном.
2. Подписаться непосредственно на событие увеличения значения.

Для того, чтобы создать событие, на которое можно подписаться, в сторе добавьте отдельным полем новый `Subject`, соответствующий событию.
Можно также создать один общий `Subject` на все события, как в примере ниже с шиной событий, только на уровне одного стора.
Но для примера рассмотрим вариант, когда на каждое событие создаётся отдельный `Subject`.

```typescript
import { Subject } from 'rxjs';
import { BehaviorStore } from '@proscom/prostore';
import { useStore, useObservableCallback } from '@proscom/prostore-react';

interface DataStoreState {
  value: number;
}

class DataStore extends BehaviorStore<DataStoreState> {
  onIncrement$ = new Subject<number>();
  onDecrement$ = new Subject<number>();

  constructor() {
    super({ value: 0 });
  }

  increment() {
    const value = this.state.value + 1;
    this.setState({ value });
    this.onIncrement$.next(value);
  }

  decrement() {
    const value = this.state.value - 1;
    this.setState({ value });
    this.onDecrement$.next(value);
  }
}

const dataStore = new DataStore();

function MyComponent() {
  const [state, store] = useStore(dataStore);
  console.log('component rendered', state.value);

  useObservableCallback(store.onIncrement$, (value) => {
    console.log('store incremented', value);
  });
  // ...
}
```

В примере выше при вызове `dataStore.increment()` в консоль будет выведено следующее:

```
component rendered 1
store incremented 1
```

[https://codesandbox.io/s/prostore-events-8z779](https://codesandbox.io/s/prostore-events-8z779)

### Шина событий

Избежать циклических зависимостей также можно, если использовать шину событий для взаимодействия между сторами.
Шина событий также поможет связать локальные сторы друг с другом, даже если у них нет прямых ссылок друг на друга.

Шина событий - это просто Observable, через который проходят какие-то события
(можно использовать что-то наподобие Actions из Redux).
Стор подписывается на события этого обзервабла и может каким-то образом реагировать на них.

```typescript
import { Subject } from 'rxjs';

interface IStoreEvent {
  type: string;
}

const EVENT_DATA_INCREMENT = 'EVENT_DATA_INCREMENT';

// Subject - это особый вид Observable, который не дублирует переданные значения
const storeEvents$ = new Subject<IStoreEvent>();

class DataStore extends BehaviorStore {
  sub = new SubscriptionManager();

  constructor() {
    super({ value: 0 });
    this.sub.subscribe(storeEvents$, this.handleStoreEvent);
  }

  destroy() {
    this.sub.destroy();
  }

  handleStoreEvent = (event) => {
    if (event.type === EVENT_DATA_INCREMENT) {
      this.setState({ value: this.state.value + 1 });
    }
  };
}

const dataStore = new DataStore();
storeEvents$.next({ type: EVENT_DATA_INCREMENT });
```

Шину событий можно также использовать для императивного слабосвязанного взаимодействия между компонентами.
Используйте ее аккуратно, чтобы не запутывать код.

При использовании `prostore-react` шину событий можно подключить в компонент с помощью хука `useObservable`:

```typescript
function MyComponent() {
  // Можно использовать глобальную переменную storeEvents$
  // но лучше пробросить её через контекст (useContext)
  useObservable(storeEvents$, (event) => {
    // Сделать что-то с storeEvents$
  });
}
```

### Пагинация с дозагрузкой в RequestStore

В случаях когда нужна пагинация с дозагрузкой (например, бесконечный скролл с дозагрузкой)
можно использовать функцию `updateData`.

Пример:

```javascript
import { insertPaginatedSlice } from '@proscom/prostore';

const myStore = new MyRequestStore({
  updateData: (data, oldData, params) => {
    const page = params.variables.page;
    const perPage = params.variables.perPage;
    return insertPaginatedSlice(data, oldData, page, perPage);
  }
});

let state;

state = await myStore.loadData({ page: 0, perPage: 2 });
// data=[1,2] oldData=null params={ variables: { page: 0, perPage: 2 }, ... }
// state.data = [1,2]

state = await myStore.loadData({ page: 1, perPage: 2 });
// data=[3,4] oldData=[1,2] params={ variables: { page: 1, perPage: 2 }, ... }
// state.data = [1,2,3,4]
```

[См. пример в CodeSandbox](https://codesandbox.io/s/prostore-react-demo-load-more-elds8?file=/src/App.tsx)
