Skip to content
This repository has been archived by the owner on Jan 27, 2021. It is now read-only.

Dynamic namespaces #94

Closed
Thom1729 opened this issue Aug 7, 2018 · 7 comments
Closed

Dynamic namespaces #94

Thom1729 opened this issue Aug 7, 2018 · 7 comments

Comments

@Thom1729
Copy link

Thom1729 commented Aug 7, 2018

Suppose that I have some original React/Redux app that already works. Then, I need to modify the application so that I can run many "instances" on the same page.

This package seems nearly ideally suited to that purpose -- but the namespacing seems designed for a fixed set of heterogeneous sub-applications with different action types rather than a homogeneous dynamic list of sub-applications sharing the same action types. In particular, redux-subspace accomplishes namespacing by changing the name of the action, whereas I would like to instead either "stuff" extra properties into the payload or (perhaps more cleanly) add an extra "context" property alongside the type and payload.

Is there a sensible way to do something like that within the context of react-subspace? If not, would it be reasonable to extend react-subspace to accommodate that use case, or does that fall outside the scope of what this package ought to do?

(I wish that there were a stable-ish "future" version of react-redux; that would make the whole thing easy!)

@mpeyper
Copy link
Contributor

mpeyper commented Aug 7, 2018

Hi @Thom1729,

Thanks for taking the time to reach out to us, I'll do my best to answer your questions and give you some insights.

Suppose that I have some original React/Redux app that already works. Then, I need to modify the application so that I can run many "instances" on the same page.

Just to make sure I understand correctly, you have a Component you want to mount and a reducer you want to use in the store an variable number of time, but once included you want them to operate indecently from one another

the namespacing seems designed for a fixed set of heterogeneous sub-applications with different action types

This was the original brief when we set out to build redux-subspace. The different action types is not quite right, in that the whole point of the namespacing was that multiple sub-applications were using the same action type for different outcomes, so we needed to isolate them away from each other.

rather than a homogeneous dynamic list of sub-applications

I have heard this request before in our other libraries (which build upon redux-subspace)

would it be reasonable to extend react-subspace to accommodate that use case, or does that fall outside the scope of what this package ought to do?

It seems to be a common desire to want a dynamic subspaces, so I'm happy to consider and discuss it here.

Is there a sensible way to do something like that within the context of react-subspace?

If you know what that array of namespace/component/reducer combinations up front then you can do something like this.

Essentially the idea is to build the namespaced reducers and SubspaceProvider wrapped components from the same array of sub-applications.

If you don't know up front, then there is nothing currently in redux-subspace to accomodate this. You could potentially do something with the redux-dynostore createInstance functionality, but you would need to be careful of not creating thing in the render functions, for reasons. I've also previously discussed how we could extend that feature to better support this use case.

The other idea I've had is similar to your suggestion of allowing subspaces to accept an instance, or context identifier. If provided the namespaced reducer still pass the actions on as normal, but will keep the results in a map of instance/context to state.

Something like:

const App = (
  <div>
    <SubspaceProvider namespace={'subApp'} instance={1}>
      <SubApp />
    </SubspaceProvider>
    <SubspaceProvider namespace={'subApp'} instance={2}>
      <SubApp />
    </SubspaceProvider>
  </div>
)

const reducer = combineReducers({
  subApp: namespaced('subApp', { instanced: true })(subAppReducer)
}

My only reservation on something like this is that namespaces are optional. I'm not sure how it would work or be set up if the a namespace was not provided.

Personally, I think using redux-dynostore is the better approach (it's literally a library for doing dynamic things with redux), but it does add more complexity to the store setup and the more packages into the bundle.

@Thom1729
Copy link
Author

Thom1729 commented Aug 7, 2018

Thanks for the reply.

Just to make sure I understand correctly, you have a Component you want to mount and a reducer you want to use in the store an variable number of time, but once included you want them to operate indecently from one another

That is exactly it.

For reference, this is the approach I'm working with, inspired by redux-subspace:

import PropTypes from 'prop-types';
class StoreModifier extends React.PureComponent {
    static propTypes = {
        mapState: PropTypes.func,
        mapAction: PropTypes.func,
    };

    static contextTypes = {
        store: PropTypes.object.isRequired,
    };

    static childContextTypes = {
        store: PropTypes.object,
    };

    getState = () => {
        const { store } = this.context;
        const { mapState = x=>x } = this.props;
        return mapState(store.getState());
    }

    dispatch = (action) => {
        const { store } = this.context;
        const { mapAction = x=>x } = this.props;
        return store.dispatch(mapAction(action))
    }

    getChildContext() {
        const { store } = this.context;

        return {
            store: {
                ...store,
                getState: this.getState,
                dispatch: this.dispatch,
            },
        };
    }

    render() { return this.props.children; }
}

The component:

const Tabs => ({ tabs }) => <ul>
    {tabs.map(tabData =>
        <li key={tabData.id}>
            <StoreModifier
                mapState={state => selectTabState(tabData.id)}
                mapAction={action => ({
                    ...action,
                    context: {
                        tabID: tabData.id,
                        ...action.context,
                    },
                })}
            >
                <MySubApplication />
            </StoreModifier>
        </li>
    )}
</ul>;

The reducer:

function tabsReducer(state = {}, action) {
    const tabID = action.context.tabID;
    if (tabID !== undefined) {
        return {
            ...state,
            [tabID]: subApplicationReducer(state[tabID], action),
        };
    } else {
        return state;
    }
}

It's based on redux-subspace, though lacking most of the features and the general structure. It could use a couple of utility functions, such as an analogue of namespaced for the reducer. It would be even simpler if Redux used the new context API -- I actually designed it for the PR, then reverse-engineered it.

I think that redux-dynostore would certainly do the trick, but I'm not sure the complexity is warranted here.

@mpeyper
Copy link
Contributor

mpeyper commented Aug 8, 2018

Haha, that looks very similar to redux-subspace v1 https://github.com/ioof-holdings/redux-subspace/blob/v1.0/src/SubspaceProvider.jsx, before we needed to care about middleware. I'm not sure what you are running with, but thunks are going to be pain for your dispatch wrapper.

I'm a bit short on time to consume all that right now, but I'll try to take a closer look soon.

@Thom1729
Copy link
Author

Thom1729 commented Aug 8, 2018

We're using saga, so no thunks. I'm not sure how to handle nonstandard action types generically. I see that the linked code has a special case for thunks.

@mpeyper
Copy link
Contributor

mpeyper commented Aug 8, 2018

TL;DR; sagas are hard

redux-saga was the catalyst for the v2 rewrite, and unfortunately for you, you're going to have to consider the following if you want to use them and do what you're doing:

Anything that delays the dispatch of an action (i.e. most async middleware - thunk, saga, observable, etc.) are going to give you a hard time. The reason for this is that the middleware that handle those special actions had no idea about your special dispatch that the component used, as they take their dispatch function directly from the root store via the middleware API.

In the case of thunks, it was relatively easy to work around. The thunk is a function that expects dispatch as a parameter, so we wrapped that in a function that, when called, provided our special dispatch function instead. In hindsight, we had effectively rewritten the thunk middleware and would have had to adjust it if the thunk api ever changed (although I think we were pretty safe on that front).

In the case of sagas, this approach was not possible. You do not dispatch a saga. There is nothing in your action to indicate that it will trigger a saga. The saga's themselves are generators for plain objects that describe the effects the saga middleware should undertake. That makes them great from a testability perspective, but awful from an encapsulation perspective. To make it worse for our use case, when writing your saga, you don't even directly use dispatch, instead your return a put effect (which is imported, not injected) and the middleware passes the given action to the root store's dispatch function, making it very difficult for you to override which dispatch function they use. Likewise for getState and select effects.

So it goes something like this:

  1. component dispatches action
  2. StoreModifier intercepts this, applies context, passes on to store
  3. store gives contextual action to middleware chain
  4. saga middleware gives action saga listening for action type
    • this will just work for you because you aren't modifying the action type itself like we are... Lucky you
  5. saga emits a put effect with an action
  6. redux middleware handles put effect and dispatches the action to the store
  7. store gives action to middleware chain
  8. go to 4

There are 2 things you can do about this... Well, 3, but one of them is give up, which I refused to do at the time (although given my time again?):

So your 2 real options are:

  1. have each saga know that they are working within a context, i.e. read the context on the actions they are listening for and pass it on for any action they create, and include the context in the selector when accessing state
  2. find some way of changing which dispatch and getState functions the middleware uses

There may be other options, but not any I could see (at the time or now). If you find any, please let me know.

Option 1 is, by far, the easier approach. You have to remember to always use the context appropriately, but you can write helper functions to alleviate that. It get also get cumbersome if you have lots of sagas to deal worry about, but you can probably deal with it.

There are a few triggers for when option 1 is not enough:

  1. you want to nest these things inside one another
    • React is good at nesting, redux (and consequently redux-middleware), not so much
  2. The thing you are wrapping does not know that it is being wrapped
    • if they don't write their sagas with context in mind, the whole thing falls apart
  3. You want to trigger contextual sagas from non-contextual actions
    • the saga do not have any implicit context within themselves, they merely pass on the provided context. No contextual action, no contextual saga
    • can be overcome by wrapping your saga in a higher-order saga to inject context in a different way, but this is then something else to manually code around in the underlying saga

If none of the above is applicable, then option 1 is fine, perhaps even preferable. Depending on the size of your project and the number of devs you have, I would suggest that 1. is a worthy goal, as we have found it to be an incredibly liberating tool for teams to be able arbitrarily slice and dice out apps up into micro-frontends and build, test and deploy different parts of out apps completely independently from other teams, not to mention the cost savings of be able to reuse complex UIs across multiple apps.

Option 2 is ultimately what we went with for redux subspace. Our approach to changing the dispatch and getState functions the saga uses was to run the saga in a seperate saga runtime and wrap it in our own saga that listens to the main saga middleware and acts as the bridge between the two. So far, this has worked, but not without caveats.

I think that redux-dynostore would certainly do the trick, but I'm not sure the complexity is warranted here.

Perhaps there is more complexity here than you expected? Perhaps I've made a simple problem more complex than it needs to be? Only you will know what is right for your project.

I assume that the tabs are not known at the time the store is created, otherwise this example I shared before should be a workable solution.

@Thom1729
Copy link
Author

Thom1729 commented Aug 9, 2018

Thank you for the in-depth reply.

Perhaps there is more complexity here than you expected?

That is fair to say. :-)

That said, after thinking on your reply for a day, I think that it should be possible to use Option 1 while solving the listed difficulties and maintaining a similar API to react-subspace.

A substore definition consists of the following four functions:

  • mapState(state) maps the global state to a sub-state. (Memoization optional but encouraged.)
  • mapAction(action) takes an action dispatched inside a substate and adds context information.
  • unmapAction(action) reverses mapAction.
  • filterAction(action) determines whether action belongs to the substore.

In my case, I want dynamic substores, so I'd define a function getTabSubstore(tabId) that takes in a tab ID and returns a substore definition. For fixed substores, you could define them directly.

In pure redux, you use a reducer decorator like this:

const tabsReducer = (state = [], action) => {
    switch (action.type) {
        case 'NEW_TAB':
            return [...state, getInitialTabState(action)];

        default:
            return state.map(tab => decorateReducer(getTabSubstore(tab.id))(state, action));
    }
}

For react-redux, the <StoreModifier> component patches the React context so that getState and dispatch go through mapState and mapAction, respectively.

For redux-saga, we use a saga decorator like so:

function* myApplicationSaga(initialTabIds) {
    for (const tabId of initialTabIds) {
        yield fork(tabSaga(tabId));
    }

    yield takeEvery('NEW_TAB', function* (action) {
        yield* tabSaga(action.payload.tabId);
    };
}

function tabSaga(tabId) {
    return decorateSaga(getTabSubstore(tabId))(mySubapplicationSaga);
}

For redux-thunk (which I'm not using in this project) it would suffice for mapAction to wrap function actions to supply them with different dispatch and getState functions.

It should be reasonably easy to define useful shorthands in terms of these primitives. For instance, a substore could be defined using a namespace with a simple factory function:

const createNamespacedSubstore = (namespace, mapState) => ({
    mapState,
    mapAction: action => ({
        ...action,
        type: `${namespace}/${action.type}`,
    }),
    unmapAction: action => ({
        ...action,
        type: action.type.replace(/^\w+\//, ''),
    }),
    filterAction: action => ({
        ...action,
        type: action.startsWith(`${namespace}/`),
    }),
});

My implementation of this approach is pretty rough, but it seems to be working well enough (including sagas). Specific thoughts:

  • It should support unlimited nesting if mapAction is chosen wisely.
  • It should be totally transparent to the child. The decorators can be applied at the parent level.
  • I've avoided redux middleware entirely.
  • This approach should be compatible with future versions of react-redux.
  • Your suggestion to use a higher-order saga was spot-on. Instead of using a separate runtime, we can simply alter the effects as they are yielded down the chain. It would be nice if redux-saga offered better support for this.
  • It will need a minor revision for redux-saga 1.0.0, as the structure of effect objects is changing.
  • unmapAction and filterAction could be combined (they should never be used apart). Perhaps unmapAction should return null if the input does not belong to the substore.

@mpeyper
Copy link
Contributor

mpeyper commented Sep 8, 2019

Closing due to inactivity. Happy to repoen if there is more discussion to be had.

@mpeyper mpeyper closed this as completed Sep 8, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

2 participants