atlassian / react-sweet-state

Shared state management solution for React
https://atlassian.github.io/react-sweet-state/
MIT License
864 stars 55 forks source link

Extend Store functionality #40

Open mjkimcorgi opened 4 years ago

mjkimcorgi commented 4 years ago

Background

As we discussed in SweetState brownbag session, I was wondering if it makes sense to add a function to extend an existing store.

The idea is that I really like sweet state that store is going to be light-weighted, but in the other side, there could be multiple stores with very similar shape. For example, we might have many different modals. We might want to have a mother store shape with mother actions. In different modals, we can extend this store.

I see benefits of extending an existing Store:

What I imagine is something like this, but, of course, could be different.

createExtendedStore(Store, {initialState, actions, name});

initialState and actions will accept only additional ones. If an existing property is given to initialState, initial state will be overrided.

Need further discussion for items:

  1. First of all, is this going to be valuable?
  2. Do we allow action override?
  3. Maybe it is a good idea to auto name it if name is not given and mother store name exist?
  4. What are the downsides?

IMHO,

  1. I think it is going to be valuable with reasons above.
  2. I think we should allow it. That is the nature of inheriting. If this is not possible, child store might have to introduce ugly actions to change child state.
  3. I think auto naming should be done if not given, it will be very useful for debugging.
  4. This is same in OOP world, though. If you design mother store not properly, we might end up creating an ugly child stores. For example, creating a mother store and extends it, only because they share some attributes and actions, not because it makes sense semantically. Mother store should be used when it makes sense semantically. It is same as you don't create parent class only because it shares some properties in OOP. Also, Alberto pointed out that when the number of contributors are many in a codebase, many people might attempt changing the mother store, which becomes nightmare in the end. But I feel like these downsides are a generic issue of programming, not a specific issue to this feature.

What do you think?

joshuatvernon commented 3 years ago

I did this recently albeit not as elegantly as I would have liked. Here's the code. It works great, however, typing everything is pretty horrible

import {
  BoundActions,
  createContainer as createReactSweetStateContainer,
  createHook as createReactSweetStateHook,
  createStore as createReactSweetStateStore,
  createSubscriber as createReactSweetStateSubscriber,
  defaults,
  Store
} from 'react-sweet-state';
import isNil from 'lodash/isNil';

import { UndefinedOr } from '../base-models/generics';
import { baseServices } from '../services';
import { isBrowser } from '../utils';
import { mergeWithEmpty } from '../utils/lodash';
import { BaseActions, baseActions } from './actions';
import { getInitialState, InitialStateScript } from './components';
import { registry } from './registry';
import { baseSelectors } from './selectors';
import { baseState } from './state';
import {
  Actions,
  BaseState,
  ContainerType,
  SelectorProps,
  Selectors,
  SelectorState,
  Services,
  Stage,
  State,
  SubscriberType,
  UseStore
} from './types';

// Override the mutator that is called by `setState` within `actions` so the we don't have to spread to perform deep merges
defaults.mutator = (currentState, partialState) => mergeWithEmpty(currentState, partialState);

export const DEFAULT_STORE_NAME = 'base-store';

// The following variables are used as singletons
let store: UndefinedOr<Store<any, any>>;
let selectors: Selectors;
let services: Services;
// `useStoreInstance` is wrapped by `useStore` which accepts generics
// so that typescript can protect custom state and custom actions
let useStoreInstance: UseStore<any, any>;
let Subscriber: SubscriberType<any, any>;
let Container: ContainerType;

/**
 * Create and return a `useStore` hook for accessing the passed in `store`
 * @param store `store` used to create a `useStore` hook
 */
export const createHook = <S extends {} = State, A extends {} = Actions>(store: Store<S, A>): UseStore<S, A> =>
  createReactSweetStateHook<S, A, SelectorState, SelectorProps<S>>(store);

/**
 * Create and return a `Subscriber` for accessing the passed in `store`
 * @param store `store` used to create a `Subscriber`
 */
export const createSubscriber = <S extends {} = State, A extends {} = Actions>(store: Store<S, A>) =>
  createReactSweetStateSubscriber<S, A, SelectorState, SelectorProps<S>>(store);

/**
 * Create and return a `Container` for accessing the passed in `store`
 * @param store `store` used to create a `Container`
 */
export const createContainer = <S extends {} = State, A extends {} = Actions>(store: Store<S, A>) =>
  createReactSweetStateContainer<S, A, SelectorProps<S>>(store, {
    onInit: () => ({ setState }, { initialState }) => setState(initialState)
  });

/**
 * Creates (or overrides) and returns the `store` and initializes (or overrides) `selectors`,
 * `services`, `useStore`, `Subscriber` and `Container` for interacting with the `store`
 *
 * NOTE: If `createStore` is not called then the `useStore`, `Subscriber` and `Container` will
 * access the default **ADGLPWebAppLibrary** `store`
 *
 * @param name `name` used for the store (Used in Redux Dev Tools)
 * @param initialState `initialState` used to set `store used to create a `Container`
 * @param initialActions `initialActions` used to update `store`
 * @param initialSelectors `initialSelectors` used to retrieve state from the `store`
 * @param initialServices `initialServices` used by actions to retrieve data to update state in the `store`
 */
export const createStore = <S extends {} = State, A extends {} = Actions>(
  name: string,
  initialState: S,
  initialActions: A,
  initialSelectors: Selectors,
  initialServices?: Services
) => {

  // Initialize (or override) the store
  store = createReactSweetStateStore<S, A>({
    initialState,
    name,
    actions: initialActions
  });

  // Initialize (or override) the selectors
  selectors = initialSelectors;

  // Initialize (or override) the services
  services = initialServices ?? baseServices;

  // Initialize (or override) the hook for accessing the store
  useStoreInstance = createHook<S, A>(store);

  // Initialize (or override) the subscriber for accessing the store
  Subscriber = createSubscriber<S, A>(store);

  // Initialize (or override) the container for accessing the store
  Container = createContainer<S, A>(store);

  return store;
};

/**
 * Initializes `store` with the DEFAULT `baseState`, `actions` and `name`
 *
 * NOTE: will be overridden if `createStore` is called from tenant web app
 */
if (isNil(store)) {
  createStore<BaseState, BaseActions>(
    DEFAULT_STORE_NAME,
    baseState,
    baseActions,
    baseSelectors,
    baseServices,
    baseState.config.stage
  );
}

/**
 * `useStore` can be used to access state and bound actions within functional components
 */
const useStore = <S extends {} = State, A extends {} = Actions>(): [S, BoundActions<S, A>] => useStoreInstance();

// Export all `store` components so they can be imported from this top level directory directly
export * from './selectors';
export * from './actions';
export * from './state';
export * from './types';
export { store, selectors, services, registry, Subscriber, useStore, Container, InitialStateScript, getInitialState };

Here are some of the generic types. I had to create RecursiveExtendedPartial to solve the case that I wanted to allow new properties at any level whilst retaining type safety for any base state properties . . .

export type ExtendedPartial<T> = {
  [P in keyof T]?: T[P];
} &
  Any;

/**
 * Recursively:
 * - Makes all properties optional
 * - Allows new properties
 * - Retains type checking if original properties are used
 */
export type RecursiveExtendedPartial<T> =
  | ({
      [P in keyof T]?: RecursiveExtendedPartial<T[P]>;
    } & { [key: string]: any })
  | T;

export interface BaseState {
   // whatever your base state would be . . .
}

export type State = RecursiveExtendedPartial<BaseState>;

export type Actions = ExtendedPartial<BaseActions>;

export type Selectors = ExtendedPartial<BaseSelectors>;

export type Services = ExtendedPartial<BaseServices>;

export type SelectorState = any;
export type SelectorProps<S = State> = {
  initialState: S;
};

export type UseStore<S = State, A extends Record<string, ActionThunk<S, A>> = Actions> = (
  ...args: any
) => [any, BoundActions<S, A>];

export type SubscriberType<S = State, A extends Record<string, ActionThunk<S, A>> = Actions> = SubscriberComponent<
  S,
  BoundActions<S, A>,
  any
>;

export type ContainerType = ContainerComponent<any>;

export type StoreApi = StoreActionApi<BaseState>;

export type ExtendedPartialStoreApi<S = State> = StoreActionApi<S>;

...and here is the extended store code...

import { Container, createStore, Stage, Subscriber, useStore } from '@my-base-state-package';

import { services } from '../services';
import { actions } from './actions';
import { selectors } from './selectors';
import { state } from './state';
import { ExtendedActions, ExtendedState } from './types';

const STORE_NAME = 'extended-store';

// Passing in state, actions, selectors and services here allows them to override the defaults for the store and components using the base store will get the newly overridden versions
const store = createStore<ExtendedState, ExtendedActions>(STORE_NAME, state, actions, selectors, services);

export { store, state, actions, selectors, useStore, Subscriber, Container };
export * from './types';
anacierdem commented 2 years ago

We have successfully used instances of the same store to share a functionality between different places without the need for "extending" a store, many times. I think extending a store is fundamentally against how react-sweet-state is constructed; i.e it will look more like a "mixin" as there won't be any straightforward way to set explicit state/function overrides without significant changes to the library.

I understand, as of today, we can reuse actions across different stores. But this might not be a good idea in terms of coupling code. An action could be doing the exactly same thing today, but we never know it might now tomorrow. I think this is the very reason a mixin or "extended store" is not a solution to this same problem. The base store can change without the extended store even being noticing it. Using a type system will help but it will also help with those shared actions.

I think current ability to share actions and container instances (via scopes) is a pretty powerful tool that does not need an extra layer of store extension rules. It already provides a tool for combining different stores and behaviours via React's well accepted composition tools. On the other hand the current system is not perfect and sharing the behaviour and data is not straightforward. But there are solutions to the problem without breaking the "compositional" structure. See #146 if you want to provide support for improving the way it works 😊