Skip to content

Instantly share code, notes, and snippets.

@daxborges
Created April 12, 2019 12:50
Show Gist options
  • Save daxborges/771b376816e888e06bdbde8632cb54f4 to your computer and use it in GitHub Desktop.
Save daxborges/771b376816e888e06bdbde8632cb54f4 to your computer and use it in GitHub Desktop.

This doc compares EP 002 - Multiple re-frame Instances (called Frames here) to what I am calling the Instance Pattern.

High Level Comparison

Problems Solved

Inversion of paradigm

  • 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 a deftype and the Component is the deftype.

Pros

  • 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.
  • 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.

Cons

  • 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).

In Depth Comparison

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.

Abstract

Proposes changes to allow multiple re-frame apps to coexist on the same (HTML) page.

Instance Pattern

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).

Introduction

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

Instance Pattern

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.

Both

The challenge is to facilitate these complicated cases while continuing to "make simple easy" (Praise to the Hickey, our savior).

Global State As A Frame

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.

Instance Pattern

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 but map->InstanceContext seems to work fine.

Both

That was the easy bit...

The Two Problems

  1. How to assoc/register handlers within a Frame
  2. Within a view function, how to subscribe or dispatch from/to the right Frame

Instance Pattern

  1. How to get handlers to operate on InstanceContext
  2. Within a view function, how to subscribe or dispatch the right InstanceContext

Both

We'll start with problem #1.

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.

Instance Pattern (Components And Context)

How do we get Components their context?

But first...

What's a Component?

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.

What's Context?

Context is simply a path that points to the instance of data in the db that a Component will operate on.

This Sounds Familiar

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.

Whats the problem with ad hoc?

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.

Problem 1: Solutions

  1. When registering event & subscription handlers pass them a Frame to register to.
  2. 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.

Instance Pattern

  1. Make it so event & subscription handlers look for an instance of InstanceContext in the second argument of their vector (By using defrecord 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.
  2. 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]).

Leaning Towards Option 1 as it's less disruptive. My examples going forward assume this usage.

Events Handlers

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.

Subscription Handlers

There needs to be a way to thread the context to a parent subscription. A new sugar like :<= could solve this.

The Complex Case: Interdependency Between Components

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:

  1. 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.
  • This case is why InstanceContext needs to operate as a map instead of a single path. It lets us thread contexts through multiple components.
  1. 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]})))
Events Calling Events

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.

Subscriptions Calling Subscriptions

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}].
What about that re-frame-undo problem?

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.

Rough Thoughts (Instance Pattern Only)

Some pain points of the Instance Pattern which need more thought but here's what I have so far...

Legacy Code

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.

Stronger Safety Guarantees

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.

Use cases
  • Rendering Instances of an app on the server, per user / session.
  • Higher developer confidence in general.
Possible Solution
  • 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 with db effect.
    • These "permissions keys" could be passed along with the InstanceContext on a key like :re-frame.core/permissions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment