Closed Thom1729 closed 5 years ago
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.
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.
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.
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.
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:
StoreModifier
intercepts this, applies context, passes on to storeThere 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:
dispatch
and getState
functions the middleware usesThere 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:
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.
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:
mapAction
is chosen wisely.react-redux
.redux-saga
offered better support for this.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.Closing due to inactivity. Happy to repoen if there is more discussion to be had.
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!)