1.1.0 • Published 4 years ago

reduxoscope v1.1.0

Weekly downloads
6
License
MIT
Repository
github
Last release
4 years ago

Reduxoscope

Tools for adding (and removing) scope to actions, made to help you reuse actions and reducers in different slices of state. Contains various functions to make it useful in real world scenario.

Tested to work with redux-toolkit.

Coming soon: helpers for integration with redux-observable and react-redux.

Table of contents

How does it work?

Scope is a unique string literal, that helps to scope action. Scope is contained in action's type. Actions can have more than one scope and scopes are not hierarchical. Usually, when function takes scope as one of arguments, it can be either string or array of strings, indicating several scopes.

scopedAction(action, scope) takes an action and appends scope to action's type field.

scopedAction({type: 'SOME_TYPE'}, 'some_scope');
//{type: @some_scope/SOME_TYPE}

scopedAction({type: 'SOME_TYPE'}, ['foo', 'bar']);
//{type: @foo@bar/SOME_TYPE}

scopedReducer(reducer, scope) wraps reducer and only calls it if action has provided scope or on initial action, dispatched by redux.

//reducer is called if action's type contains 'some_scope'
scopedReducer(reducer, 'some_scope')
//reducer is called if action's type contains 'foo' or 'bar' scope
scopedReducer(reducer, ['foo', 'bar'])

Note @ and / symbols in action's type. Scoped action has:

  • @ at the beginning of type, marking first scope
  • at least one symbol long scope. Scope can be any string literal, but SCOPE SHOULD NOT CONTAIN @ or / SYMBOLS
  • @ before any consecutive scopes
  • / after last scope

Imposing such rules on scoped type shape allows us to implement useful functions, such as getScopes(action), hasScope(action, [scope]), removeScopes(action) etc. and not misinterpret non scoped types, containing @ or / symbols.

Examples of some action types:

  • @a@b/some@type/completed - ✔ scoped type. scope is ['a', 'b'], type is some@type/completed
  • @@INIT@@ - ❌ not scoped type
  • @@INIT/REDUX - ❌ not scoped type
  • @foo@/bar - ❌ not scoped type

Basic Example

Let's say, you have state and somewhere in it there is paging, that's being managed with pagingReducer and some pagingAction.

//state shape
state = {
    clients: {
        paging: {/*...*/}
    }
};

Suddenly, you need to add paging somewhere else! But if you just add another paging object and reducer, both of them will change after pagingAction is dispatched.

import {createReducer, configureStore} from '@reduxjs/toolkit';
import {combineReducers} from 'redux';

//reducer, created with createReducer helper from 'redux-toolkit'
const pagingReducer = createReducer(/*...*/);

//here we use pagingReducer in both clientReducer and productReducer.
// This leads to both paging objects affected with pagingAction
const clientReducer = combineReducers({
    paging: pagingReducer,
});

const productsReducer = combineReducers({
    paging: pagingReducer,
});

const rootReducer = combineReducers({
    clients: clientReducer,
    products: productsReducer,
});

const state = {
    clients: {
        paging: {/*...*/}
    },
    products: {
        paging: {/*...*/}
    },
};

const store = configureStore({reducer: rootReducer, preloadedState: state});

To avoid the issue and reuse already existing pagingReducer and pagingAction, we can use scopedReducer and scopedAction functions.

import {createReducer, configureStore} from '@reduxjs/toolkit';
import {combineReducers} from 'redux';
import {scopedReducer, scopedAction} from 'reduxoscope';

const clientsScope = 'foo';
const productsScope = 'bar';

const pagingReducer = createReducer(/*...*/);


const clientReducer = combineReducers({
    paging: scopedReducer(pagingReducer, clientsScope),
});

const productsReducer = combineReducers({
    paging: scopedReducer(pagingReducer, productsScope),
});

const rootReducer = combineReducers({
    clients: clientReducer,
    products: productsReducer,
});

const state = {
    clients: {
        paging: {/*...*/}
    },
    products: {
        paging: {/*...*/}
    },
};

const store = configureStore({reducer: rootReducer, preloadedState: state});

//now, to modify paging, we need to dispatch action with appropriate scope

//clients.paging is affected, products.paging is not
store.dispatch(scopedAction(pagingAction(), clientsScope));

//products.paging is affected, clients.paging is not
store.dispatch(scopedAction(pagingAction(), productsScope));

//neither products.paging nor clients.paging are affected
store.dispatch(pagingAction());

API Reference

scopedAction(action: AnyAction, scope: string | string[]): AnyAction

Takes action and scope(s) and returns new action with scopes, appended to type field.

Does not mutate original action.

Example:

scopedAction({type: 'SOME_TYPE'}, 'some_scope');
//{type: @some_scope/SOME_TYPE}

scopedAction({type: 'SOME_TYPE'}, ['foo', 'bar']);
//{type: @foo@bar/SOME_TYPE}

scopedReducer(reducer: Reducer, scope: string | string[]): Reducer

Takes reducer and scope(s) and returns new reducer, that only calls passed reducer if scoped action contains any of passed scopes and on initial action, dispatched by redux.

Action, passed down to provided reduced, is cleared from all scopes.

Example:

const reducer = (state = initialState, action) => {    
    switch (action.type) {
        case 'ADD': //implementation
        case 'REDUCE': //implementation
    }
}

const reducerWithScope = scopedReducer(reducer, 'foo');

//when dispatching these actions, reducer is called and changes state 
scopedAction({type: 'ADD'}, 'foo');
scopedAction({type: 'REDUCE'}, 'foo');
scopedAction({type: 'ADD'}, ['bar', 'foo']);
scopedAction({type: 'REDUCE'}, ['bar', 'foo']);
//when dispatching these actions, reducer is not called
{type: 'ADD'}
{type: 'REDUCE'}
scopedAction({type: 'ADD'}, 'bar');
scopedAction({type: 'REDUCE'}, 'bar');
scopedAction({type: 'ADD'}, ['bar', 'baz']);
scopedAction({type: 'REDUCE'}, ['bar', 'baz']); 


//also takes array of scopes
const reducerWithScope = scopedReducer(reducer, ['foo', 'bar']);
//when dispatching these actions, reducer is called and changes state 
scopedAction({type: 'ADD'}, 'foo');
scopedAction({type: 'REDUCE'}, 'foo');
scopedAction({type: 'ADD'}, 'bar');
scopedAction({type: 'REDUCE'}, 'bar');
scopedAction({type: 'ADD'}, ['foo', 'bar']);
scopedAction({type: 'REDUCE'}, ['foo', 'bar']);
scopedAction({type: 'ADD'}, ['foo', 'baz']);
scopedAction({type: 'REDUCE'}, ['foo', 'baz']);
scopedAction({type: 'ADD'}, ['bar', 'baz']);
scopedAction({type: 'REDUCE'}, ['bar', 'baz']);

Important Note

Due to how redux works, when action is dispatched, ALL REDUCERS ARE CALLED, no matter action's type. That means, if you have non scoped reducers, that do not test action's type against exact string, such reducers may modify state even when scoped action is passed.

We are using redux-toolkit in next example:

const matchedReducer = createReducer(initialState, builder => builder
    .addMatcher(
        (action) => action.type.endsWith('do'),
        (state, action) => {
            //this function is called, when any action with type, ending with 'do', is dispatched.
            //This includes scoped actions, such as scopedAction({type: 'add_todo'}, 'some_scope'),
            //because scope is added to the beginning of action's type.
        }
    )
);
//if you need matchedReducer to filter out scoped actions, you can change matcher as follows
const matchedReducer = createReducer(initialState, builder => builder
    .addMatcher(
        (action) => !hasScope(action) && action.type.endsWith('do'),
        (state, action) => {}
    )
);

//some more examples
const regularReducer = (state = initialState, action) => {
    //this function is called, when any action is dispatched
    switch (action.type) {
        case 'add_todo': {
            //but code here is not evaluated, when scoped action is dispatched
        }
    }
};

const caseReducer = createReducer(initialState, builder =>
    builder.addCase('add_todo', (state, action) => {
        //this function is not called, when scoped action is dispatched
    })
);

getScopes(action: AnyAction): string[] | undefined

Returns scopes if action has any, otherwise returns undefined.

Examples:

const action = {type: 'some_type/something@example'};
const singleScopeAction = scopedAction(action, 'foo');
const severalScopesAction = scopedAction(action, ['foo', 'bar', 'baz']);

getScopes(singleScopeAction);
//['foo']
getScopes(severalScopesAction);
//['foo', 'bar', 'baz']
getScopes(action);
//undefined

hasScope(action: AnyAction, scope?: string | string[]): boolean

Checks if action has any of provided scopes. If scope is not provided, checks if action has any scope.

Example:

const action = {type: 'some_type'};
const singleScopeAction = scopedAction(action, 'foo');
const severalScopesAction = scopedAction(action, ['foo', 'bar', 'baz']);

hasScope(action);
//false
hasScope(singleScopeAction, 'foo');
//true
hasScope(singleScopeAction, ['foo', 'qux', 'something', 'bar']);
//true
hasScope(singleScopeAction);
//true
hasScope(singleScopeAction, 'bar');
//false
hasScope(severalScopesAction, 'foo');
//true
hasScope(severalScopesAction, ['foo', 'qux', 'something', 'bar']);
//true
hasScope(severalScopesAction);
//true
hasScope(severalScopesAction, 'qux');
//false
hasScope(severalScopesAction, ['qux', 'something']);
//false

removeScopes(action: AnyAction): AnyAction

Returns action with type, cleared of scopes.

Does not mutate original action.

Examples:

const action = {type: 'some_type'};
const singleScopeAction = scopedAction(action, 'foo');

removeScopes(singleScopeAction);
//{type: 'some_type'}
removeScopes(action);
//{type: 'some_type'}

pluckScopes(action: AnyAction): {action: AnyAction, scopes?: string[]}

Clears action of scopes and returns object, containing action without scopes and all scopes it previously had.

Does not mutate original action.

Examples:

const action = {type: 'some_type'};
const singleScopeAction = scopedAction(action, 'foo');

pluckScopes(singleScopeAction);
//{action: {type: 'some_type'}, scopes: ['foo']}
removeScopes(action);
//{action: {type: 'some_type'}}

scopedActionCreator(actionCreator: ActionCreator, scope: string | string[])

Returns new action creator, that adds scope to created action.

scopedDispatch(dispatch: Dispatch, scope: string | string[])

Returns new dispatch function, that adds scope to dispatched actions.

scopedType(type: string, scope: string | string[])

Returns type with appended scope.

Most likely you will never need to use this function. Used internally, but exposed if you really need it.

scopedType('SOME_TYPE', 'some_scope');
// @some_scope/SOME_TYPE

scopedType('SOME_TYPE', ['foo', 'bar']);
// @foo@bar/SOME_TYPE