-
Notifications
You must be signed in to change notification settings - Fork 55
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
Dynamic container implementation #196
Conversation
Please review @theKashey and @anacierdem 🙏 |
}; | ||
|
||
const memoMatcher = ((cache) => (Store) => { | ||
if (!cache.has(Store)) cache.set(Store, matcher(Store)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's an interesting trick 👍
storeState, | ||
actions, | ||
hooks, | ||
unsubscribe: storeState.subscribe(() => hooks.onStoreUpdate(Store)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we add here a "marker" about this being a "contained store", not a global one?
That would greatly help with #190
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we accept one of the proposed solutions (eg a new behavior
attribute), then do we actually need it?
I was thinking RSS could check in the Registry whenever the store currently being initialised is following the correct behaviour, and if not throw an async error (so we don't break code).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change looks very natural.
src/components/container.js
Outdated
function useApi( | ||
memoMatcher, | ||
getContainedStore, | ||
{ globalRegistry = defaultRegistry, getStore = defaultRegistry.getStore } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it be?
{ globalRegistry = defaultRegistry, getStore = defaultRegistry.getStore } | |
{ globalRegistry = defaultRegistry, getStore = globalRegistry.getStore } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does not really matter much, as both are coming from context at the same time. These defaults are there because I'm worried enzyme shallow might not populate context properly and blow up. See #57
I might actually check if it is still an issue with hooks...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked in product and shallow
enzyme
seems fine. Removed the default fallback.
Didn't have a chance to look into this in detail, but I will. Does this also resolve #97? |
Yes, via createContainer((s) => true, options) Where |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mainly went over the documentation and API in general as @theKashey is already looking at the implementation. I have a few concerns:
- I am not exactly sure how this will ease A better way to orchestrate multiple stores #146. While I understand that this new container type will enable creating a single container wrapping multiple stores, passing information between is still an issue IINM. It does not seem possible to call other store's actions or change its props from any of the event callbacks. Or similarly it is not possible to change some other store's state (which should be bad anyways).
- You are also right about as long as a similar functionality to
onStoreInit
andonStoreCleanup
is not available forcreateContainer
it won't help much. The only option is migrating tocreateDynamicContainer
but only matching the relevant store to mimiccreateContainer
, which would be a little weird.
Most importantly, I think this addition is against the core premise of RSS, which is keeping independent atomic stores that are "contained" at various distinct boundaries. By making the boundary dynamic, it is essentially killing this opinionated view.
Maybe I didn't exactly get how this is useful. One usecase coming to my mind is merging multiple containers at the same level into a single one, but what is the benefit here? While trying to isolate the different store's concerns from each other, I feel like we are introducing a mechanism to merge them in a weird way, which is not very useful.
The original idea is designing a store, and then deciding where to put those containers depending on the usage patterns. With this one, we design the usage pattern first. I agree that managing containers is hard but my gut tells me this is not the correct direction to improve it.
I don't object to it too much as it is not affecting the existing API, but I don't see the value here. I may be totally off, please help me understand if that's the case. I would be really happy to see some real-life scenarios. In the provided example, it is trivial to replace ThemingContainer
with a component composed of two containers created by createContainer
, I don't think it is worth this additional complexity.
|
||
- `onStoreInit` _(Function)_: an action that will be triggered on each store initialisation. If you define multiple containers sharing the same scope, this action will still only be run **once** by one of the container components, so ensure they receive the same props. | ||
|
||
- `onStoreUpdate` _(Function)_: an action that will be triggered when a store state changes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should include the function parameters here. Especially for this one, it is not clear how I will know which store instance is changing state? Just by looking at the documentation, it is not clear. Same for the other function configs as well.
|
||
- `isGlobal` _(bool)_: by default, Container defines a local store instance. This prop will allow child components to get data from the global store's registry instance instead | ||
|
||
- `scope` _(string)_: this option will allow creating multiple global instances of the same store. Those instances will be automatically cleaned once all the containers pointing to the scoped version are removed from the tree. Changing a Container `scope` will: create a new Store instance, make `onInit` action run again and all child components will get the data from it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which onInit
? Is that a typo?
// Assume we have a ColorsStore and a FontSizesStore with `tags: ['theme']` | ||
|
||
const ThemeContainer = createDynamicContainer({ | ||
matcher: (Store) => Store.tags.includes('theme'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do I define the tags here? Need something in the docs IMO, currently only way to know is digging the example files.
() => | ||
({ setState, getState }, { initialTheme }) => { | ||
const state = getState(); | ||
if ('colors' in state) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of duck typing the state, wouldn't be better to check the store against some reference. b/c the parameters to the action creator are not clear here, I am not sure how.
@@ -27,13 +28,22 @@ const TodoView = ({ id, count }: TodoViewProps) => { | |||
*/ | |||
const App = () => { | |||
const [count, setCount] = useState(0); | |||
|
|||
useEffect(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is an unrelated change. Ideally would be better if we can exclude from this PR.
Stores are still independent, but you don't need to micromanage every one of them and as a result it might be easier to have more independent stores without the necessity to pull all of them to the top-level boundary of a "entity". Strangely there are not many examples of similar pattern, but I think jotai-molecule is almost the same one, just a bit less "automatic" as you have to use it explicitly. Hoisted stores (reactjs/rfcs#241) are also more than related. |
Thank you for the references. I will give that rfc a read. I now think is is more important to improve the examples so that it is even clearer for the users. |
I am still thinking about this in the back of my mind :) How is this different from a single store where we keep both states but use them behind RSS selectors? It will have the same performance benefits and it will be clear that those states are contained together just by looking at the store implementation. The new dynamic container breaks this link, you need to find the decoupled dynamic container floating around to understand the behaviour. It may even cause an explosion of dynamic containers where you hunt for a weird store behaviour throughout the codebase to find relevant containers that may affect that specific store. Think about many containers matching stores based on a state they contain and initializing some stuff on it, how can we find those containers in a big project? |
The trigger uses case was ensuring all RSS stores used inside a section of an app are scoped (no global leakage). If you have multiple teams creating stores, having a dynamic container that captures them all and scopes them automatically can reduce the risk of misconfiguration. Also it would also allow such scoping to happen without importing the stores, so without impacting bundle size. Re global leakage, it seems like #190 might be a better discussion/solution.
This is a good callout. Maybe the matcher API is too powerful, and might accidentally match unwanted stores. If we move back to a more explicit API it might be more predictable, as we were talking in #184 : createTagContainer('tag-foo'); // or family Re #146 , my idea was to allow createTagContainer('theme', {
onStoreInit: (store, { dispatchTo }) => () => {
if(...) dispatchTo(ColorsStore, actions.doSomething()); // trigger actions on another store
}
}) The reason this is possible here is because we can validate that the store called by |
For multiple team setups, having a dynamic container really makes sense. This makes this clearly an "admin" feature. Because if handed directly to those teams, it is possible that they misuse the tool by using the dynamic container to manage their own stores, which would cause the logic to get scattered.
I don't think the problem is the power of the matcher, and the problem is not accidentally matching but rather intentionally creating such dynamic containers. Even in case of #146 I think the dynamic container will be hard to find around. On my proposed solution, the cross-store logic is contained in the definition of the store by means of actual "dependencies", which makes the intent of the store clear. OTOH, a dynamic container represents a boundary where any store can be manipulated/managed and by definition does not belong to any particular store. This is both a strength (no need to import member stores) and a downside because it does not have enough restriction on its usage. Maybe if we can find a way to be more explicit about the collection of stores in a specific boundary, we may be in much better shape. Even tags can be a too dynamic without changing the direction. I wonder if a unique Symbol per container would solve the issue when we approach it from a different perspective; This is where we define the dynamic container: // This couples the tag with the container, the only way to get captured by this container is importing
// `themeTag` from this module. It should be a lightweight import for the consumer.
export const [ThemingContainer, themeTag] = createTaggedContainer({
...
}); Then the actual stores reference the exported tag; const Store = createStore({
initialState,
actions,
tags: [themeTag]
}); This reverses the usage pattern. Now the central authority decides on the tags but the downside is everyone now is supposed to put the tag on their container, and we are back to square one with the configuration risk. Even in case of string tags, how are you planning to make sure all the stores have the same tag to capture? That still has the same configuration risk and is not very different than the unique tag pattern above? |
The last proposal also enhances discoverability - you no longer have to think which stores your Container will "match" as the information will be explicitly powered by dependencies. I also think that the current implementation is "good to go" as it is generic enough to handle any case - from simple |
I know that this has been a long convo, but given the large user base of RSS (and the fact that I don't wanna have to make new majors) I'm cautious on shipping things that will become a pain to unship. For instance, I liked your last API proposal @anacierdem and while implementing it, I've discovered that it poses a circular dependency problem when we try to use the // theming.js
import { Store as ColorsStore } from './colors.js';
export const [ThemingContainer, themingTag] = createTaggedContainer({
onStoreInit: (store) => ({ ... }) => {
if (store === ColorsStore) ...
}
});
// colors.js
import { themeTag } from './theming.js'
export const Store = createStore({initialState, actions, tags: [themingTag] }); The Store needs the tag, but the tagged container needs the store to send the data/actions into the right one. Right now the "flow" is that containers depend on Stores. If we were to invert that relationship we would end up with devs inadvertently creating circles between container, stores and actions. To avoid this, I can think of only 2 things:
The latter sound compelling: // theming.js
const ThemingContainer = createContainer();
// colors.js
import { ThemingContainer } from './theming.js'
export const Store = createStore({
initialState,
actions,
containedBy: [ThemingContainer],
onInit: () => (api, props) => ...
onContainerUpdate: () => () => ...
}); however I question whenever there is an actual need for multi-container support, as that complicates the type safety around the props. What would be the use case? ( especially after |
This is a good example of how long theoretical conversations do not stand a minute of a practical application. From a cohesion point of view moving all hooks into store definition is the right approach... Now let's find a good reason not to do that.
|
This comment was marked as outdated.
This comment was marked as outdated.
Exactly, this is why I am totally ok with the I initially also thought Still, having the handlers on the store rather than the container makes more sense to me as well. It would also make sure we can make sure stores are initialized regardless of the existence of a container. I am not exactly sure why
The users should be able to tag the container with their custom prop and decide on what to do based on it if per-container initialization is a necessity. That was something we never felt the need for. I am also thinking how can we have the two completely different API living together? |
Looking at the ~100
First and easy option is exposing a new
Agree, we can probably release a first version with a single container support and then see if there is demand for more |
I can definitely say that in a particular repository of ours, almost all (if not all) of the containers are for actual initialization. Also we have found the ability to change the location of the containers after implementing the store, very useful so that we can decide on the data boundaries later. We have never used it for data mocking IINM :) So, I cannot provide further input there. OTOH, for those stores, we only ever had a single container that is strategically placed, so even we need to put them in |
it will create more problems than could possibly resolve. Let's do our best to create a pit success and not let developers to fall in some other hole.
Which is something I think we need to address. The global nature of sweet-state makes it ease to use, but everything should have logical bounds - a standard example would be a boundary on application level (aka big context providers, or redux). That is "reusable component model" and predictability of environment we value relay for. |
Cool, thanks for this great convo, team! I think we have a better direction now.
This extension of the API should allow progressive migration to |
Implementation of #184. The new container type will be created as:
This new path also offers the chance to support #186, with a more store-driven API. Callbacks init/update/clear are now bound to store instances, and we provide a clearer
onPropsUpdate
.Similarly, #146 now becomes easier to manage and implement either via consumers code or by providing a second argument to
onStoreBla
that gives access to all stores initialised by the same container.What is still not supported (and maybe never will) is multi container matching. As if a container matches a store, it will decide its scope (local, global) and if global then it won't match any other parent.