This doc compares EP 002 - Multiple re-frame Instances (called Frames
here) to what I am calling the Instance Pattern
.
- Frames
- Multiple App instances.
- Instance Pattern
- Composing Complex Components.
- Multiple app instances.
- Frames
- Instances of event handlers, subscription handlers, etc paired with its data / state, much like an OOP Class.
- Instance Pattern
- Groupings of event handlers, subscription handlers, etc (a Component) registered only once.
- Components operate on instances of data much like
deftype
. In other words the data is an instance of adeftype
and the Component is thedeftype
.
- Components operate on instances of data much like
- Groupings of event handlers, subscription handlers, etc (a Component) registered only once.
- Frames
- Aims to be a tradition lib.
- Arguably easier to grok.
- Higher level of "safety" due to siloed state.
- Only requires refactoring of views in existing apps to support app instances.
- I'm not sure if this is true for event handlers that call dispatch / dispatch-n.
- Aims to be a tradition lib.
- Instance Pattern
- Solves multiple problems in one go.
- Encourages reusability and composition.
- Keeps the door open for app instance integration down the road.
- Recognizes that apps will probably share singletons of the environment "Browser url"
- Can use a primitive that works with React context.
- Requires minimal changes to re-frame core.
- Frames
- Silos app instances, closing the door on future integration.
- Doesn't address shared environment singletons.
- Has complexity between Frames, Registrars & Hot reloading which needs further investigation.
- Requires considerable changes to re-frame core.
- Instance Pattern
- Less intuitive (Not easy, but maybe more simple).
- Less "safe".
- Depends on namespaced keys and proper context.
- In the in depth comparison there are some thoughts on how this can possibly be improved.
- May require refactoring of existing apps event & subscription handlers for app instance support (Maybe not. More exploration is necessary).
Note: I heavily summarize the Frames EP, refer to it for complete descriptions. Also, please correct me if you find any cases where my summary misses the point.
Proposes changes to allow multiple re-frame apps to coexist on the same (HTML) page.
Proposes the concept of "Components" and their instances, a paradigm shift of conceptualizing apps as Components, and changes to re-frame to facilitate this pattern.
The goal is to encourage and simplify authoring reusable re-frame code which can also solve the problem of multiple apps on the same (HTML) page. In other words: Components all the way down (or up, depending on your perspective).
Currently there can be only one instance of re-frame
on the page which in rare cases can be problematic.
- Two or more instances of the same app on one page
- Two or more instances of different apps on the same page
We regularly deal with the concept of components and instances in our apps in an ad hoc way. By coming up with a consistent way of dealing with components and their instances and thinking of our apps as "just another component" we can solve the following problems.
- Two or more instances of a component in one app.
- I.e. two side by side todo lists in the same app.
- Two or more instances of the same app on one page.
- An app is just a big complex component, of which you want two or more instances of on the same page.
- Two or more instances of different apps on the same page.
- It really doesn't matter if they're different, we just think of them as two or more instances of two or more Components on the same page.
The challenge is to facilitate these complicated cases while continuing to "make simple easy" (Praise to the Hickey, our savior).
Scoop up all state, put it in a defrecord call it Frame.
Add a new API function create-frame
which works as a Frame factory.
Create as many re-frame apps as you need with create-frame
.
Leave Global State. Leverage namespaced keywords and context.
Create a defrecord, call it InstanceContext
, in which you place context in the form of a path to an instance of the data in the db.
(defrecord InstanceContext [])
- I will address why we want defrecord instead of a map, or simply a single path later.
Create as many instance contexts, pointing to their respective path in the DB, as you need.
(def instance-context-1 (map->InstanceContext {::component-key [:path :to :component :instance 1]}))
(def instance-context-2 (map->InstanceContext {::component-key [:path :to :component :instance 2]}))
- Maybe we want a factory in the API
create-instance-context
butmap->InstanceContext
seems to work fine.
That was the easy bit...
- How to assoc/register handlers within a
Frame
- Within a view function, how to
subscribe
ordispatch
from/to the rightFrame
- How to get handlers to operate on
InstanceContext
- Within a view function, how to
subscribe
ordispatch
the rightInstanceContext
We'll start with problem #1.
What's the new relationship between Frames
and registrars
?
- Re-frame currently has one registrar.
- Should Frames and registrars be 1:1?
- If not how will this work with Figwheel & hot reloading?
- For multiple instance of the same app on one page we can probably keep one registrar.
- The harder case: multiple instances of different apps.
- Maybe we can still use one registrar but it would be "safer" to encapsulate access between frames.
- Maybe we can register
packages
where the Frame states which packages it needs.- Consider re-frame-undo.
- Maybe use namespace of the registration id.
- More maybes: see this section of the EP.
How do we get Components their context?
But first...
A Component is a grouping of event handlers, subscription handlers, etc. It's conceptually the same as packages
mentioned in the Frames EP, like re-frame-undo. The crucial difference is that Components require context (more on this in a second).
Note: To safely compose Components without conflict they should use namespaced keywords for their events, subscriptions and key in the InstanceContext. This is technically not a requirement but it's critical if you want to rest easy knowing your components will always play nice with others. You should really be namespacing your event & subscription keys regardless of this proposal.
Context is simply a path that points to the instance of data in the db that a Component will operate on.
You've probably written the equivalent of a Component with ad hoc context. Consider the simple case of "Items" where you have events & subscription handlers that operate on items as well as individual items. We tend to pass an item id as an argument (dispatch [:update-item 1])
. Under the hood of the events you resolve that id 1
to some path like [:items 1]
. That's all fine until you want to have two instances of items. You then run into a cascading effect where you'll likely pass a second id and need to rewrite your handlers to take it into account (dispatch [:update-item :items-a 1])
resolving to [:items-1 1]
. In this case the id's are serving as the context.
Note: In this example it would probably also make sense to split Items into two components: Item & Items.
The main problem with the ad hoc solution is that it doesn't compose. Id's require the component itself to know where its data lives (it's implied that the id is pointing to a different path in the DB). This means there is no way to externally dictate where the instance lives or to create new instances.
On the other hand, using paths as context makes the component instance externally explicit. Best of all, we know exactly how to compose paths.
- When registering event & subscription handlers pass them a Frame to register to.
- When registering event & subscription handlers optionally pass them a package key. Then when creating a Frame define which packages it uses.
- I drew some conclusions here about "optionally pass them a package key" since text seemed incomplete.
Leaning Towards Option 2 as it's less disruptive.
- Make it so event & subscription handlers look for an instance of
InstanceContext
in the second argument of their vector (By usingdefrecord
instead of a map this is trivial)- E.g.
(dispatch [::event-id instance-context arg])
,(subscribe [::sub-id instance-context arg])
. - In the case of subscriptions the context needs to be able to be threaded to parent subscriptions.
- E.g.
- Update the internals of re-frame core to support the
InstanceContext
coming through as a separate arg- E.g.
(dispatch instance-context [::event-id arg])
,(subscribe instance-context [::sub-id arg])
.
- E.g.
Leaning Towards Option 1 as it's less disruptive. My examples going forward assume this usage.
A set of Interceptors can do all the work here:
- Strip the instance-context out of the vector and place it into the coeffects.
- This could be split into two interceptors.
- Switch the db to point to the instance-context (like the
path
interceptor).
In all cases they could conditionally work only if instance-context is present.
There needs to be a way to thread the context to a parent subscription. A new sugar like :<=
could solve this.
Lets say "Component A" depends on "Component B". Specifically the event handlers and / or subscription handlers in Component A dispatch / subscribe to events / subscriptions in Component B.
The requirement here is that Component A needs to make sure the calls to Component B's events / subscriptions contain the context necessary for Component B.
There are two scenarios of how to Component A can get the context Component B needs:
- Component B's context is included as another key in InstanceContext when it is sent in the calls to Component A's event / subscription.
;; In the view or some other component
(def instance-context (map->InstanceContext {::component-a/context [:path :to :instance :A]
::component-b/context [:path :to :instance :B]}))
- In this case Component A simply needs to make sure the "piggybacked" context gets passed along.
- This means Component A doesn't need to know anything about Component B's context. It just needs to make sure it passes along the value of
::component-b/context
.- Component A could technically just pass along the instance-context it originally got and be completely oblivious to
::component-b/context
's existence.- This requires care for subscriptions where unnecessary keys can have negative performance implications.
- Component A could technically just pass along the instance-context it originally got and be completely oblivious to
- This case is why InstanceContext needs to operate as a map instead of a single path. It lets us thread contexts through multiple components.
- Component A knows how to make Component B's context based on its own context.
;; In the view or some other component
(def instance-context (map->InstanceContext {::component-a/context [:path :to :instance :A]}))
;; In Component A
(def new-instance-context (map->InstanceContext {::component-b/context [:path :to :instance :B]})))
In the case of an event handler in Component A dispatching an event in Component B it's really no different than dispatching from the view. There's not performance implications of including extra keys like in a subscription so I expect most usage will simply pass along the same instance-context that was received.
Unfortunately there is a performance penalty to including unnecessary information in a subscription vector. For this reason it's necessary to have some way to whitelist context-keys.
Keeping the same :<=
sugar mentioned above in mind:
- For whitelisting:
:<= [::some-sub-key [::some-whitelist-key]]
for whitelisting. - For renaming & whitelisting at the same time:
:<= [::some-sub-key {::some-whitelist-key ::some-new-key-name-to-use}]
.
The crux here is that re-frame-undo has its own set of unique state, namely its copies of previous db states. Components like this would need to have a way to register their unique state using their instance-context. This could technically be done ad hoc using local atoms (as is done in re-frame-undo's) case but it could be nice to continue to use the single registrar. Maybe something like (reg-component-state ::component-id [:instance :path] some-state)
and then have ways to read and mutate that state using the same key and path pair.
The issue here are essentially identical between Frames
and the Instance Pattern
. However the Instance Pattern has the advantage that it can use a primitive "JS Object" up until the moment of dispatch
or subscribe
which addressees Solution Sketch 3 in the Frames EP.
Some pain points of the Instance Pattern which need more thought but here's what I have so far...
It would be nice to have a way to quickly turn an existing app into an instance without having to refactor any event or subscription handlers. Here are a few starter points:
- Could add a special "root" re-frame namespaced key to InstanceContext which lets existing apps work as instances?
- E.g.
:re-frame.core/db
(map->InstanceContext {:re-frame.core/db [:root :level :db :path :for :app :instance]}))
- Would require substantial re-frame core refactor.
- There would need to be a way to automatically thread the context to dispatch & dispatch-n effects.
- E.g.
The Instance Pattern doesn't provide any protections against random mutations to an instance of data. From one perspective this could be considered Polymorphism, and a good thing. However there are some cases where the developer wants strong guarantees on what has permission to read / mutate a certain instance (path) in the DB.
- Rendering Instances of an app on the server, per user / session.
- Higher developer confidence in general.
- Permissioning nodes of the DB (and its children).
- There could be some sort of registry that associates nodes of the DB tree to "permissions keys".
- When an event handler or a subscription tries to read or write to a permissioned node (or its children) in the DB, it must be passed the "permission key".
- Enforcing illegal reads is the harder problem.
- One rough thought is that both events and subscriptions remove the permissioned nodes from the returned global db unless their "permission key" is passed.
- Writing is easier to enforce.
- Require a
permission
effect or something similar which is enforced internally in tandem withdb
effect.
- Require a
- Enforcing illegal reads is the harder problem.
- These "permissions keys" could be passed along with the InstanceContext on a key like
:re-frame.core/permissions
.