UNPKG

easy-peasy

Version:

Vegetarian friendly state for React

1,072 lines (953 loc) 25.9 kB
import { Component } from 'react'; import { UnknownAction, compose, Dispatch as ReduxDispatch, Middleware, Observable, Reducer as ReduxReducer, Store as ReduxStore, StoreEnhancer, } from 'redux'; import { O } from 'ts-toolbelt'; export type ReduxAction = UnknownAction; /** * Picks only the keys of a certain type */ type KeysOfType<A extends object, B> = { [K in keyof A]-?: A[K] extends B ? K : never; }[keyof A]; /** * This allows you to narrow keys of an object type that are index signature * based. * * Based on answer from here: * https://stackoverflow.com/questions/56422807/narrowing-a-type-to-its-properties-that-are-index-signatures/56423972#56423972 */ type IndexSignatureKeysOfType<A extends Object> = { [K in keyof A]: A[K] extends { [key: string]: any } | { [key: number]: any } ? string extends keyof A[K] ? K : number extends keyof A[K] ? K : never : never; }[keyof A]; type InvalidObjectTypes = string | Array<any> | RegExp | Date | Function; type IncludesDeep3<Obj extends object, M extends any> = O.Includes< Obj, M > extends 1 ? 1 : O.Includes< { [P in keyof Obj]: Obj[P] extends object ? O.Includes<Obj, M> : 0; }, 1 >; type IncludesDeep2<Obj extends object, M extends any> = O.Includes< Obj, M > extends 1 ? 1 : O.Includes< { [P in keyof Obj]: Obj[P] extends object ? IncludesDeep3<Obj[P], M> : 0; }, 1 >; type IncludesDeep<Obj extends object, M extends any> = O.Includes< Obj, M > extends 1 ? 1 : O.Includes< { [P in keyof Obj]: Obj[P] extends object ? IncludesDeep2<Obj[P], M> : 0; }, 1 >; type StateResolver< Model extends object, StoreModel extends object, Result = any, > = (state: State<Model>, storeState: State<StoreModel>) => Result; type StateResolvers<Model extends object, StoreModel extends object> = | [] | Array<StateResolver<Model, StoreModel, any>>; type AnyFunction = (...args: any[]) => any; type ActionEmitterTypes = Action<any, any> | Thunk<any, any, any, any, any>; type ActionListenerTypes = ActionOn<any, any> | ThunkOn<any, any, any>; type ActionTypes = | ActionEmitterTypes | ActionListenerTypes | EffectOn<any, any, any>; interface ActionCreator<Payload = void> { (payload: Payload): void; type: string; z__creator: 'actionWithPayload'; } interface ThunkCreator<Payload = void, Result = any> { (payload: Payload extends undefined ? void : Payload): Result; type: string; startType: string; successType: string; failType: string; z__creator: 'thunkWithPayload'; } type ActionOrThunkCreator<Payload = void, Result = void> = | ActionCreator<Payload> | ThunkCreator<Payload, Result>; type Helpers<Model extends object, StoreModel extends object, Injections> = { dispatch: Dispatch<StoreModel>; fail: AnyFunction; getState: () => State<Model>; getStoreActions: () => Actions<StoreModel>; getStoreState: () => State<StoreModel>; injections: Injections; meta: Meta; }; // #region Helpers /** * This utility will pull the state within an action out of the Proxy form into * a natural form, allowing you to console.log or inspect it. * * @param state - The action state * * @example * * ```typescript * import { debug, action } from 'easy-peasy'; * * const model = { * todos: [], * addTodo: action((state, payload) => { * console.log(debug(state)); * state.todos.push(payload); * console.log(debug(state)); * }) * } * ``` */ export function debug<StateDraft extends object = {}>( state: StateDraft, ): StateDraft; // #endregion // #region Listeners type ValidListenerProperties<ActionsModel extends object> = { [P in keyof ActionsModel]: P extends IndexSignatureKeysOfType<ActionsModel> ? never : ActionsModel[P] extends ActionListenerTypes ? P : ActionsModel[P] extends object ? IncludesDeep<ActionsModel[P], ActionListenerTypes> extends 1 ? P : never : never; }[keyof ActionsModel]; type ListenerMapper< ActionsModel extends object, K extends keyof ActionsModel, > = { [P in K]: ActionsModel[P] extends ActionOn<any, any> ? ActionCreator<TargetPayload<any>> : ActionsModel[P] extends ThunkOn<any, any, any> ? ThunkCreator<TargetPayload<any>, any> : ActionsModel[P] extends object ? RecursiveListeners<ActionsModel[P]> : ActionsModel[P]; }; type RecursiveListeners<ActionsModel extends object> = ListenerMapper< ActionsModel, ValidListenerProperties<ActionsModel> >; /** * Filters a model into a type that represents the listener actions/thunks * * @example * * type OnlyListeners = Listeners<Model>; */ export type Listeners<Model extends object = {}> = RecursiveListeners<Model>; // #endregion // #region Actions type ValidActionProperties<ActionsModel extends object> = { [P in keyof ActionsModel]: P extends IndexSignatureKeysOfType<ActionsModel> ? never : ActionsModel[P] extends ActionEmitterTypes ? P : ActionsModel[P] extends object ? IncludesDeep<ActionsModel[P], ActionEmitterTypes> extends 1 ? P : never : never; }[keyof ActionsModel]; type ActionMapper<ActionsModel extends object, K extends keyof ActionsModel> = { [P in K]: ActionsModel[P] extends Action<any, any> ? ActionCreator<ActionsModel[P]['payload']> : ActionsModel[P] extends Thunk<any, any, any, any, any> ? ActionsModel[P]['payload'] extends void ? ThunkCreator<void, ActionsModel[P]['result']> : ThunkCreator<ActionsModel[P]['payload'], ActionsModel[P]['result']> : ActionsModel[P] extends object ? RecursiveActions<ActionsModel[P]> : ActionsModel[P]; }; type RecursiveActions<ActionsModel extends object> = ActionMapper< ActionsModel, ValidActionProperties<ActionsModel> >; /** * Filters a model into a type that represents the action/thunk creators. * * @example * * ```typescript * import { Actions, useStoreActions } from 'easy-peasy'; * import { StoreModel } from './my-store'; * * function MyComponent() { * const doSomething = useStoreActions( * (actions: Actions<StoreModel>) => actions.doSomething * ); * } * ``` */ export type Actions<Model extends object = {}> = RecursiveActions<Model>; // #endregion // #region State type StateTypes = Computed<any, any, any> | Reducer<any, any> | ActionTypes; type StateMapper<StateModel extends object> = { [P in keyof StateModel]: StateModel[P] extends Generic<infer T> ? T : P extends IndexSignatureKeysOfType<StateModel> ? StateModel[P] : StateModel[P] extends Computed<any, any, any> ? StateModel[P]['result'] : StateModel[P] extends Reducer<any, any> ? StateModel[P]['result'] : StateModel[P] extends object ? StateModel[P] extends InvalidObjectTypes ? StateModel[P] : IncludesDeep<StateModel[P], StateTypes | ActionTypes> extends 1 ? RecursiveState<StateModel[P]> : StateModel[P] : StateModel[P]; }; type FilterActionTypes<Model extends object> = { [K in keyof Model as Model[K] extends ActionTypes ? never : K]: Model[K]; }; type RecursiveState<Model extends object> = StateMapper< FilterActionTypes<Model> >; /** * Filters a model into a type that represents the state only (i.e. no actions) * * @example * * ```typescript * import { State, useStoreState } from 'easy-peasy'; * import { StoreModel } from './my-store'; * * function MyComponent() { * const stuff = useStoreState((state: State<StoreModel>) => state.stuff); * } * ``` */ export type State<Model extends object = {}> = RecursiveState<Model>; // #endregion // #region Store + Config + Creation /** * Creates a store. * * https://easy-peasy.dev/docs/api/create-store.html * * @example * * ```typescript * import { createStore } from 'easy-peasy'; * * interface StoreModel { * todos: string[]; * } * * const store = createStore<StoreModel>({ * todos: [] * }); * ``` */ export function createStore< StoreModel extends object = {}, InitialState extends undefined | object = undefined, Injections extends object = {}, >( model: StoreModel, config?: EasyPeasyConfig<InitialState, Injections>, ): Store<StoreModel, EasyPeasyConfig<InitialState, Injections>>; /** * Configuration interface for stores. * * @example * * ```typescript * import { createStore } from 'easy-peasy'; * import model from './my-model'; * * const store = createStore(model, { * devTools: false, * name: 'MyConfiguredStore', * }); * ``` */ export interface EasyPeasyConfig< InitialState extends undefined | object = undefined, Injections extends object = {}, > { compose?: typeof compose; devTools?: boolean | object; disableImmer?: boolean; enhancers?: StoreEnhancer[]; initialState?: InitialState; injections?: Injections; middleware?: Array<Middleware<any, any, any>>; mockActions?: boolean; name?: string; version?: number; reducerEnhancer?: (reducer: ReduxReducer<any, any>) => ReduxReducer<any, any>; } export interface MockedAction { type: string; [key: string]: any; } export interface AddModelResult { resolveRehydration: () => Promise<void>; } /** * An Easy Peasy store. This is essentially a Redux store with additional enhanced * APIs attached. * * @example * * ```typescript * import { Store } from 'easy-peasy'; * import { StoreModel } from './store'; * * type MyEasyPeasyStore = Store<StoreModel>; * ``` */ export interface Store< StoreModel extends object = {}, StoreConfig extends EasyPeasyConfig<any, any> = EasyPeasyConfig< undefined, {} >, > extends ReduxStore<State<StoreModel>> { addModel: <ModelSlice extends object>( key: string, modelSlice: ModelSlice, ) => AddModelResult; clearMockedActions: () => void; dispatch: Dispatch<StoreModel>; getActions: () => Actions<StoreModel>; getListeners: () => Listeners<StoreModel>; getMockedActions: () => MockedAction[]; persist: { clear: () => Promise<void>; flush: () => Promise<void>; resolveRehydration: () => Promise<void>; }; reconfigure: <NewStoreModel extends object>(model: NewStoreModel) => void; removeModel: (key: string) => void; /** * Interoperability point for observable/reactive libraries. * @returns {observable} A minimal observable of state changes. * For more information, see the observable proposal: * https://github.com/tc39/proposal-observable */ [Symbol.observable](): Observable<State<StoreModel>>; } // #endregion // #region Dispatch /** * Enhanced version of the Redux Dispatch with action creators bound to it * * @example * * import { Dispatch } from 'easy-peasy'; * import { StoreModel } from './store'; * * type DispatchWithActions = Dispatch<StoreModel>; */ export type Dispatch< StoreModel extends object = {}, Action extends ReduxAction = UnknownAction, > = Actions<StoreModel> & ReduxDispatch<Action>; // #endregion // #region Types shared by ActionOn and ThunkOn type Target = ActionOrThunkCreator<any> | string; type TargetResolver<Model extends object, StoreModel extends object> = ( actions: Actions<Model>, storeActions: Actions<StoreModel>, ) => Target | Array<Target>; interface TargetPayload<Payload> { type: string; payload: Payload; result?: any; error?: Error; resolvedTargets: Array<string>; } type PayloadFromResolver< Resolver extends TargetResolver<any, any>, Resolved = ReturnType<Resolver>, > = Resolved extends string ? any : Resolved extends ActionOrThunkCreator<infer Payload> ? Payload : Resolved extends Array<infer T> ? T extends string ? any : T extends ActionOrThunkCreator<infer Payload> ? Payload : T : unknown; // #endregion // #region Thunk type Meta = { path: string[]; parent: string[]; }; /** * Declares a thunk against your model type definition. * * https://easy-peasy.dev/docs/typescript-api/thunk.html * * @param Model - The model that the thunk is being bound to. * @param Payload - The type of the payload expected. Set to undefined if none. * @param Injections - The type for the injections provided to the store * @param StoreModel - The root model type for the store. Useful if using getStoreState helper. * @param Result - The type for the expected return from the thunk. * * @example * * import { Thunk } from 'easy-peasy'; * * interface TodosModel { * todos: Array<string>; * addTodo: Thunk<TodosModel, string>; * } */ export interface Thunk< Model extends object, Payload = undefined, Injections = any, StoreModel extends object = {}, Result = any, > { type: 'thunk'; payload: Payload; result: Result; } /** * Declares an thunk against your model. * * Thunks are typically used to encapsulate side effects and are able to * dispatch other actions. * * https://easy-peasy.dev/docs/api/thunk.html * * @example * * ```typescript * import { thunk } from 'easy-peasy'; * * const store = createStore({ * login: thunk(async (actions, payload) => { * const user = await loginService(payload); * actions.loginSucceeded(user); * }) * }); * ``` */ export function thunk< Model extends object = {}, Payload = undefined, Injections = any, StoreModel extends object = {}, Result = any, >( thunk: ( actions: Actions<Model>, payload: Payload, helpers: Helpers<Model, StoreModel, Injections>, ) => Result, ): Thunk<Model, Payload, Injections, StoreModel, Result>; // #endregion // #region Listener Thunk export interface ThunkOn< Model extends object, Injections = any, StoreModel extends object = {}, > { type: 'thunkOn'; } export function thunkOn< Model extends object = {}, Injections = any, StoreModel extends object = {}, Resolver extends TargetResolver<Model, StoreModel> = TargetResolver< Model, StoreModel >, >( targetResolver: Resolver, handler: ( actions: Actions<Model>, target: TargetPayload<PayloadFromResolver<Resolver>>, helpers: Helpers<Model, StoreModel, Injections>, ) => any, ): ThunkOn<Model, Injections, StoreModel>; // #endregion // #region Action /** * Represents an action. * * @example * * import { Action } from 'easy-peasy'; * * interface Model { * todos: Array<Todo>; * addTodo: Action<Model, Todo>; * } */ export type Action<Model extends object, Payload = void> = { type: 'action'; payload: Payload; result: void | State<Model>; }; /** * @param {boolean} [immer=true] - If true, the action will be wrapped in an immer produce call. Otherwise, the action will update the state directly. **/ interface Config { immer?: boolean; } /** * Declares an action. * * https://easy-peasy.dev/docs/api/action * * @example * * import { action } from 'easy-peasy'; * * const store = createStore({ * count: 0, * increment: action((state) => { * state.count += 1; * }) * }); */ export function action<Model extends object = {}, Payload = any>( action: (state: State<Model>, payload: Payload) => void | State<Model>, config?: Config, ): Action<Model, Payload>; // #endregion // #region Listener Action export interface ActionOn< Model extends object = {}, StoreModel extends object = {}, > { type: 'actionOn'; result: void | State<Model>; } export function actionOn< Model extends object, StoreModel extends object, Resolver extends TargetResolver<Model, StoreModel>, >( targetResolver: Resolver, handler: ( state: State<Model>, target: TargetPayload<PayloadFromResolver<Resolver>>, ) => void | State<Model>, config?: Config, ): ActionOn<Model, StoreModel>; // #endregion // #region Computed /** * Represents a computed property. * * @example * * import { Computed } from 'easy-peasy'; * * interface Model { * products: Array<Product>; * totalPrice: Computed<Model, number>; * } */ export interface Computed< Model extends object, Result, StoreModel extends object = {}, > { type: 'computed'; result: Result; } type DefaultComputationFunc<Model extends object, Result> = ( state: State<Model>, ) => Result; type ExtractReturnTypes<T extends readonly ((...args: any[]) => any)[]> = [ ...{ [K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : never; }, ]; export function computed< Model extends object = {}, Result = void, StoreModel extends object = {}, Resolvers extends StateResolvers<Model, StoreModel> = StateResolvers< Model, StoreModel >, >( resolversOrCompFunc: Resolvers | DefaultComputationFunc<Model, Result>, compFunc?: (...args: ExtractReturnTypes<Resolvers>) => Result, ): Computed<Model, Result, StoreModel>; // #endregion // #region EffectOn export interface EffectOn< Model extends object = {}, StoreModel extends object = {}, Injections = any, > { type: 'effectOn'; } type Change<Resolvers extends StateResolvers<any, any>> = { prev: ExtractReturnTypes<Resolvers>; current: ExtractReturnTypes<Resolvers>; action: { type: string; payload?: any; }; }; export type Dispose = () => any; export function effectOn< Model extends object = {}, StoreModel extends object = {}, Injections = any, Resolvers extends StateResolvers<Model, StoreModel> = StateResolvers< Model, StoreModel >, >( dependencies: Resolvers, effect: ( actions: Actions<Model>, change: Change<Resolvers>, helpers: Helpers<Model, StoreModel, Injections>, ) => undefined | void | Dispose | Promise<Dispose>, ): EffectOn<Model, StoreModel, Injections>; // #endregion // #region Reducer /** * A reducer type. * * Useful when declaring your model. * * @example * * import { Reducer } from 'easy-peasy'; * * interface Model { * router: Reducer<ReactRouterState>; * } */ export type Reducer<State = any, Action extends ReduxAction = UnknownAction> = { type: 'reducer'; result: State; }; /** * Allows you to declare a custom reducer to manage a bit of your state. * * https://github.com/ctrlplusb/easy-peasy#reducerfn * * @example * * import { reducer } from 'easy-peasy'; * * const store = createStore({ * counter: reducer((state = 1, action) => { * switch (action.type) { * case 'INCREMENT': return state + 1; * default: return state; * } * }) * }); */ export function reducer<State>( reducer: ReduxReducer<State>, config?: Config, ): Reducer<State>; // #endregion // #region Generics /** * Used to declare generic state on a model. * * @example * * interface MyGenericModel<T> { * value: Generic<T>; * setValue: Action<MyGenericModel<T>, T>; * } * * const numberModel: MyGenericModel<number> = { * value: generic(1337), * setValue: action((state, value) => { * state.value = value; * }) * }; */ export class Generic<T> { type: 'ezpz__generic'; } /** * Used to assign a generic state value against a model. * * @example * * interface MyGenericModel<T> { * value: Generic<T>; * setValue: Action<MyGenericModel<T>, T>; * } * * const numberModel: MyGenericModel<number> = { * value: generic(1337), * setValue: action((state, value) => { * state.value = value; * }) * }; */ export function generic<T>(value: T): Generic<T>; // #endregion Generics // #region Hooks /** * A React Hook allowing you to use state within your component. * * https://easy-peasy.dev/docs/api/use-store-state.html * * Note: you can create a pre-typed version of this hook via "createTypedHooks" * * @example * * import { useStoreState, State } from 'easy-peasy'; * import { StoreModel } from './store'; * * function MyComponent() { * const todos = useStoreState((state: State<StoreModel>) => state.todos.items); * return todos.map(todo => <Todo todo={todo} />); * } */ export function useStoreState< StoreState extends State<any> = State<{}>, Result = any, >( mapState: (state: StoreState) => Result, equalityFn?: (prev: Result, next: Result) => boolean, ): Result; /** * A React Hook allowing you to use actions within your component. * * https://easy-peasy.dev/docs/api/use-store-actions.html * * Note: you can create a pre-typed version of this hook via "createTypedHooks" * * @example * * import { useStoreActions, Actions } from 'easy-peasy'; * * function MyComponent() { * const addTodo = useStoreActions((actions: Actions<StoreModel>) => actions.todos.add); * return <AddTodoForm save={addTodo} />; * } */ export function useStoreActions< StoreActions extends Actions<any> = Actions<{}>, Result = any, >(mapActions: (actions: StoreActions) => Result): Result; /** * A react hook that returns the store instance. * * https://easy-peasy.dev/docs/api/use-store.html * * Note: you can create a pre-typed version of this hook via "createTypedHooks" * * @example * * import { useStore } from 'easy-peasy'; * * function MyComponent() { * const store = useStore(); * return <div>{store.getState().foo}</div>; * } */ export function useStore< StoreModel extends object = {}, StoreConfig extends EasyPeasyConfig<any, any> = EasyPeasyConfig< undefined, {} >, >(): Store<StoreModel, StoreConfig>; /** * A React Hook allowing you to use the store's dispatch within your component. * * https://easypeasy.now.sh/docs/api/use-store-dispatch.html * * Note: you can create a pre-typed version of this hook via "createTypedHooks" * * @example * * import { useStoreDispatch } from 'easy-peasy'; * * function MyComponent() { * const dispatch = useStoreDispatch(); * return <AddTodoForm save={(todo) => dispatch({ type: 'ADD_TODO', payload: todo })} />; * } */ export function useStoreDispatch< StoreModel extends object = {}, >(): Dispatch<StoreModel>; /** * A utility function used to create pre-typed hooks. * * https://easypeasy.now.sh/docs/api/create-typed-hooks.html * * @example * import { StoreModel } from './store'; * * const { useStoreActions, useStoreState, useStoreDispatch, useStore } = createTypedHooks<StoreModel>(); * * useStoreActions(actions => actions.todo.add); // fully typed */ export function createTypedHooks<StoreModel extends object = {}>(): { useStoreActions: <Result>( mapActions: (actions: Actions<StoreModel>) => Result, ) => Result; useStoreDispatch: () => Dispatch<StoreModel>; useStoreState: <Result>( mapState: (state: State<StoreModel>) => Result, equalityFn?: (prev: Result, next: Result) => boolean, ) => Result; useStore: () => Store<StoreModel>; }; // #endregion // #region StoreProvider /** * Exposes the store to your app (and hooks). * * https://easypeasy.now.sh/docs/api/store-provider.html * * @example * * import { StoreProvider } from 'easy-peasy'; * import store from './store'; * * ReactDOM.render( * <StoreProvider store={store}> * <App /> * </StoreProvider> * ); */ export class StoreProvider<StoreModel extends object = {}> extends Component<{ store: Store<StoreModel>; children?: React.ReactNode; }> {} // #endregion // #region Context + Local Stores interface StoreModelInitializer< StoreModel extends object, RuntimeModel extends undefined | object, > { (runtimeModel?: RuntimeModel): StoreModel; } export function createContextStore< StoreModel extends object = {}, Injections extends object = {}, RuntimeModel extends undefined | object = StoreModel, StoreConfig extends EasyPeasyConfig<any, any> = EasyPeasyConfig< undefined, Injections >, >( model: StoreModel | StoreModelInitializer<StoreModel, RuntimeModel>, config?: StoreConfig, ): { Provider: React.FC<{ children?: React.ReactNode; runtimeModel?: RuntimeModel; injections?: Injections | ((previousInjections: Injections) => Injections); }>; useStore: () => Store<StoreModel, StoreConfig>; useStoreState: <Result = any>( mapState: (state: State<StoreModel>) => Result, equalityFn?: (prev: Result, next: Result) => boolean, ) => Result; useStoreActions: <Result = any>( mapActions: (actions: Actions<StoreModel>) => Result, ) => Result; useStoreDispatch: () => Dispatch<StoreModel>; useStoreRehydrated: () => boolean; }; export function useLocalStore< StoreModel extends object = {}, StoreConfig extends EasyPeasyConfig<any, any> = EasyPeasyConfig< undefined, {} >, >( modelCreator: (prevState?: State<StoreModel>) => StoreModel, dependencies?: any[], storeConfig?: ( prevState?: State<StoreModel>, prevConfig?: StoreConfig, ) => StoreConfig, ): [State<StoreModel>, Actions<StoreModel>, Store<StoreModel, StoreConfig>]; // #endregion // #region Persist export interface PersistStorage { getItem: (key: string) => any | Promise<any>; setItem: (key: string, data: any) => void | Promise<void>; removeItem: (key: string) => void | Promise<void>; } export interface Transformer { in?: (data: any, key: string, fullState?: any) => any; out?: (data: any, key: string, fullState?: any) => any; } export interface PersistConfig<Model extends object> { allow?: Array<keyof Model>; deny?: Array<keyof Model>; mergeStrategy?: 'mergeDeep' | 'mergeShallow' | 'overwrite'; migrations?: { migrationVersion: number; [key: number]: ( state: Partial<Model & { [key: string | number]: any }>, ) => void; }; storage?: 'localStorage' | 'sessionStorage' | PersistStorage; transformers?: Array<Transformer>; } export interface TransformConfig { blacklist?: Array<string>; whitelist?: Array<string>; } export function createTransform( inbound?: (data: any, key: string, fullState?: any) => any, outbound?: (data: any, key: string, fullState?: any) => any, config?: TransformConfig, ): Transformer; export function persist<Model extends object = {}>( model: Model, config?: PersistConfig<Model>, ): Model; export function useStoreRehydrated(): boolean; // #endregion