Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend Store functionality #40

Open
mjkimcorgi opened this issue Aug 21, 2019 · 2 comments
Open

Extend Store functionality #40

mjkimcorgi opened this issue Aug 21, 2019 · 2 comments

Comments

@mjkimcorgi
Copy link

mjkimcorgi commented Aug 21, 2019

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:

  • We can remove boiler plate code for similarly shaped stores. But carefully only when it makes sense. Dialog state is a good example.
  • We have a single test to test mother store. Child store will need to write only tests that are specific to the child store. This also removes more boiler plate of test.
  • 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. Extending store solves this issue.
  • This is just extending an existing store, that does not add much complexity to the library and should be quite simple to implement.

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
Copy link

joshuatvernon commented Aug 24, 2020

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
Copy link

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 😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants