day8 / re-frame

A ClojureScript framework for building user interfaces, leveraging React
http://day8.github.io/re-frame/
MIT License
5.43k stars 715 forks source link

Make it okay to use `subscribe` in Form-1 components #218

Closed mike-thompson-day8 closed 7 years ago

mike-thompson-day8 commented 8 years ago

Should re-frame now allow subscribe within a renderer?

In concrete terms, can this (current approach):

(defn my-view      ;; Form-2
  [] 
  (let  [a   (subscribe [:a])]     ;; subscribe is put in the Form-2 setup 
    (fn []   
      [:div  @a])))

... to become this (proposed):

(defn my-view      ;; Form-1
  []
  [:div  @(subscribe [:a])])))

Notes:

  1. Previously this worked <= v 0.7.0 but it caused a new subscription to be created on each rerender, which meant it was a sufficiently bad idea that it effectively didn't work.
  2. As of v0.8.0, it now may work (theory untested) but it is still in the realm of implementation detail - it may work because now subscriptions are cached and de-duplicated.
  3. Is this something we want re-frame to guarantee in a future release? It looks like it could be very convenient.

I probably won't struggle too much to make this happen BUT if it naturally falls out of the current implementation it could be a good idea to officially guarantee the behavior. Just so everyone knows where they stand.

Someone needs to test if this works already ... does the new cache ensure that subscriptions are reused and not constantly created and destroyed on each rerender. Actually, in the time it has taken to write this ticket, I've gone from being 80% sure this will work to about 99% sure it will work. But a test needs to be done. And some more thinking about possible edge cases.

danielcompton commented 8 years ago

I'd be inclined to vote against this change, but am willing to be persuaded. First, lets talk about the benefits:

  1. Subscribing in form-1 components is easier for people. It is quite common for confusion between form-1 and form-2 components to come up, and this lets people ignore that distinction.
  2. form-1 components have less characters and syntax

AFAICT those are the benefits to allowing/encouraging this behaviour. Now for the downsides:

  1. IMO it makes re-frame/reagent more complex, as you now need to remember the special case of why you can do subscriptions in form-1 components, but you shouldn't create reactions/ratoms yourself in them.
  2. I'd argue that it masks the true nature of React/Reagent's lifecycle in a way likely to lead to more confusion further down the line.
  3. You're hitting the 'lookup the cached reaction' code path on every render. For a single component, or even for many it's probably not a problem, but it is unnecessary work to run on every component render. EDIT: you also need to deref the subscription and reagent will need to do equality checks on every render.
  4. It puts restrictions on re-frame development and what we can and can't do with subscriptions in the future. See the current Clojure mailing list thread on changing what the ns forms accepts to be more conservative.
  5. It may mask bugs when people use form-1 components when they meant to use form-2 components
  6. It makes components harder to read, as there isn't a clear place for where all data comes from.
  7. It makes form-1 components more complex to review, as you need to be looking out for data subscriptions.

A wise man said "the best gift you can give yourself is a good problem statement" 😄. I think we've started at the point of "should we support subscriptions in form-1 components", without examining the problem deeply enough.

If the problem is that people find the distinction between the two confusing, then alternative idea would be for re-frame to warn the user (and give remediation instructions) when they create subscriptions outside of the React context of componentWillMount, e.g. when users create subscriptions inside render methods.

There is a definite learning curve to get over with Reagent form-1 and form-2 components, but I'm not sure if this is the right answer.

Perhaps I haven't understood the problem myself though...

mbertheau commented 8 years ago

I'm in favor of such a change. I agree with the benefits as outlined by @danielcompton. Essentially this change would make re-frame simpler and easier to use, which is a key goal of re-frame.

Now let me answer to each of the downsides as pointed out by @danielcompton, in order:

  1. There's a reason now for when you need to use a Form-2 component instead of a Form-1 component. subscribe-in-Form-1 just changes that reason. Right now it's pretty common to need a Form-2 component. With subscribe-in-Form-1, you'll need Form-2 components much less often, so in sum re-frame with subscribe-in-Form-1 would still be significantly simpler. The distinction between creating a subscription and creating a reaction/ratom yourself is probably much less pronounced for the developer familiar with how re-frame works internally, from the perspective of user of re-frame, the step to creating reactions/ratoms yourself is much bigger.
  2. It'd be beneficial if the need to understand the lifecycle arised later in the process of learning re-frame, or maybe never at all for applications that are not too complex. re-frame, building on reagent and react already does a very good job at hiding a lot of the complexities of the browser platform (rAF, event queue processing, applying changes to the DOM). This could be another step.
  3. That'll be necessary, yes. Not sure how much impact that really has though.
  4. Also true in a way. On the other hand you could make subscribe-in-Form-1 be called something other than subscribe to make it a thing different from subscriptions, should that be necessary.
  5. This is no different now than it will be with subscribe-in-Form-1. But there will be less cases of actual bugs, if subscribe-in-Form-1 works.
  6. I find the @ stands out sufficiently. I can also see a macro defcomp that specifies incoming data in a way similar to reg-sub, so this shouldn't be a reason against subscribe-in-Form-1.
  7. Same argument here.

All in all my opinion is that subscribe-in-Form-1 has the potential to make re-frame use significantly simpler than it is now.

superstructor commented 8 years ago

Unless I misunderstand @danielcompton's point was that subscribe-in-Form-1 will make re-frame more complex, not simpler. If so, I totally agree and don't think this change should be made.

The macro on the wiki makes form-2 more convenient for users who want to opt in to something that looks like subscribe-in-Form-1 but it shouldn't be the default to hide how reagent/re-frame works in this regard; e.g.,

(defn- to-sub
  [[binding sub]]
  `[~binding (re-frame.core/subscribe ~sub)])

(defn- to-deref
  [binding]
  `[~binding (deref ~binding)])

(defmacro let-sub
  [bindings & body]
  `(let [~@(mapcat to-sub (partition 2 bindings))]
     (fn []
       (let [~@(mapcat to-deref (take-nth 2 bindings))]
         ~@body))))

(defn panel
  []
  (let-sub [v [:some-sub]]
     ....
mike-thompson-day8 commented 8 years ago

Moving from the Form-2 approach to Form-1 has the benefit of removing two corner cases which have bugged me mightily for a while ...

Corner Case #1

Consider this classic Form-2:

(defn view
  [x]      ;; x is a value like 42
  (let [sub (subscribe [:something x])]  ;; <-- note use of x's value
    (fn [x] 
      [:div  str(@sub)])))

This view has a problem if x is a value and it changes. Ie. if it were 42 the first render, and 10 the next. The subscription will be forever linked to the first value of x (42) and won't be modified for any subsequent value of x.

To solve this problem, we created dynamic subscriptions - the 2-arity version of subscribe. If x was a ratom (input signal to the subscription), you would rewrite like this, to solve the problem:

(defn view
  [x]      ;; x is a ratom
  (let [sub (subscribe [:something] [x])]  ;; reactive `x` supplied in 2nd vector
    (fn [x] 
      [:div  str(@sub)])))

I've never really liked the dynamic subscriptions solution. It means someone has to FIRST get an annoying paper cut and, only then, discover dynamic subscriptions to fix that paper cut. Nicer if it all just worked the first time. Nicer if the programmer's mental model was matched.

If this view was written as a Form-1, it just works first time, no paper cut and no eventual discovery of dynamic subscriptions:

(defn view
  [x]      ;; x is a value
  (let [sub (subscribe [:something x])] 
    [:div  str(@sub)]))

When x changes, the view re-renders, creating a new subscription. The old one is disposed of. No need of dynamic subscriptions.

Corner Case #2

Consider this slightly contrived code:

(defn view
   []  
   (let [errors?    (subscribe [:errors?])
         error-list (subscribe [:err-list])]
      (fn [] 
        (if @errors?
          [error-view @error-list]
          [:div "No errors")))

Notice the if statement. Only its true path derefs error-list; the false path does not.

Now, imagine further that, for the entire existence of a [view ...] instance, @errors? is always falsy. Consequences: error-list would never be derefed (the true path is never taken). Which means it would never be "captured" by the view component. Which, in turn, means it won't be properly released when the view disappears - further background here.

So, there would be a zombie subscription. (This virtually never happens in practice .. the examples have to be a bit contrived and poorly written).

This is a corner case, and it is easily fixed if you just add an apparently meaningless @error-list above the if ... or, better, just make the show-errors view do the subscription itself. But no matter how unlikely it is to happen, and no matter there is both a (hacky) fix, and a refactor fix, I dislike that it is even possible.

Again, use of a Form-1 means this issue never ever even comes up. It just works.

Result

I continue to be of a mind to provide this guarantee in v0.9.0. I see it brings a nice simplicity and removes corner cases.

mike-thompson-day8 commented 8 years ago

It now occurs to me that we can go one step further in this process to ALSO remove a source of annoying, newbie bugs (at the expense of adding to the re-frame API).

Consider this code:

(defn view
  [x]      ;; x is a value
  (let [sub (subscribe [:something x])] 
    [:div  (str sub)]))

See the bug? Hint: it is in the last line.

Further hint: something is missing.

Yep, it should have been @sub

We could create a new API function called, say, input-signal which is pretty much just this:

(defn input-signal 
   [v]
   (deref (subscribe v)))     ;; @(subscribe v)

In effect, this function does nothing other than to automatically deref the subscription. Forgetting to deref a subscription is such an annoying pebble in the shoe, and being able to never trip across that problem again is quite attractive.

Use of this new function (instead of subscribe) would look like this:

(defn view
  [x]      ;; x is a value like 42
  (let [val (input-signal [:something x])]  ;; <-- note use
    [:div  (str val)])))        ;; no @ needed on val

The argument against this proposal: we'd then have two functions doing something similar, leading to possible confusion. I have a test which I use when assessing adding something: how many pages of documentation do I have to write to satisfactorily explain this, and that gives me pause for thought on adding this fn.

Naming Competition: if we did do this, what name should be used? signal-val? It really is a kind of subscription -- one that delivers a value - so maybe the name should reflect that - sub-val? Other thoughts, ideas and opinions welcome. query? stream? pull?

YurySolovyov commented 8 years ago

listen ?

stumitchell commented 8 years ago

I think that the underlying problem here is the reagent component api. The differences between form 1, 2 and 3 components are confusing and IMHO are the biggest initial hurdle when using reagent, as it is difficult to grok which parts of your component are run when the data pass to the component changes. Unfortunately, there doesn't seem to be a way to use dynamic subscriptions in this context without putting them in the render-fn. However, this means that we seem to be creating a form 1.5 component where outside state is injected into form 1 components, which may cause confusion.

On the other hand in reagent it was always possible to access external state, as form 1 components can assess globally defined ratoms. see the following code from the reagent tutorial

(ns example
  (:require [reagent.core :as r]))

(def click-count (r/atom 0))

(defn counting-component []
  [:div
   "The atom " [:code "click-count"] " has value: "
   @click-count ". "
   [:input {:type "button" :value "Click me!"
            :on-click #(swap! click-count inc)}]])

Would it be possible to move ALL subscriptions within a form 1 component. If so should this then become the default? Maybe that may be a better way to do things?

hipitihop commented 8 years ago

@danielcompton some valid concerns about downsides, but I also acknowledge corner case #1 & #2 in follow up from @mikethompson I'm a little reserved about catering for newbie bugs via automatic deref via input-signal which imho also further hides the reagent/react comp details, nonetheless my competition submission watch ?

stumitchell commented 8 years ago

competition entry is dref-subscribe

mike-thompson-day8 commented 8 years ago

It is now officially guaranteed that you can use subscribe in a Form-1 Reagent view function. This guarantee will officially be a part of the 0.9.0 release, but it already works in v0.8.0.

The issue of a further helper function is not yet decided.

gpellis commented 8 years ago

Does this also green light putting subscriptions in the computation function passed to reg-sub?

mike-thompson-day8 commented 8 years ago

@gpellis no, absolutely not. reg-subs are as before, no change.

yatesco commented 7 years ago

Seeing as this is still open: my 2p is that the original proposal solves the right problem wrongly. The problem is the reagent API and how reactions interact with the component lifecycle.

Changing the behaviour of another lib's API always leads to confusion. Adding a new API to make the other lib's API easier is the way to go, and whilst subscribe is re-frame and not reagent it is close enough to reaction to overlap.

I think the idea of a new API that doesn't require derefing is sufficiently far from the mechanics of reaction so as not to confuse. input-signal is the right direction but again, confuses signal and value. My vote would be for signal->value or subscription->value.

Just my 2p :-).

mike-thompson-day8 commented 7 years ago

This guarantee is officially part of v0.9, which is about to be released, so I'm going to close this (it also works in v0.8, but wasn't offical)

For the moment, I can't figure out how to add a listen function without confusing people by having two ways of doing almost the same. Unless I'm careful, people will start using listen in reg-sub signal fns, etc.

So, I'll let everyone add their own 4 line listen function, which I will describe how to do in the docs.

mortenschioler commented 1 year ago

Sorry for bumping an ancient thread, but I couldn't find anywhere more appropriate to discuss this.

We still can't just subscribe willy-nilly in form-1 components, because Reagent makes assumptions about the difference between render and re-render, and no amount of caching by re-frame can fix this. The only situation where it works correctly is in the (admittedly extremely common) special case where all logical branches of the render fn derefs the same, constant set of subscriptions (and this relies on caching to work without memory leaks a.k.a. zombie subscriptions).

The reason is that only the initial render, where mount happens, listens for signal derefs, and only the case when the identity of the component (not its arguments) changes triggers unmount (incurring subscription disposal) and remount (incurring a new listen-and-attach phase with the new component and its sbuscriptions).

As a consequence, this is wrong:

(defn bad-component
  [foo?]
  (if foo?
   ;; The mounted component will react only to the sub made in the inital render, and the other sub will be
   ;; born a zombie when made because Reagent isn't listening for derefs on re-render
    @(rf/subscribe [:sub/bar])
    @(rf/subscribe [:sub/baz])))

And so is this:

(defn another-bad-component
  [x]
  ; when x changes after the component is mounted, the component is still stuck reacting to the old sub
  @(rf/subscribe [:sub/foo x]))

The above statements are consistent with logic and my own testing, and are evident by the copious amount of my blood on the floor from papercuts from re-frame.

I've made a quite nice generic solution for this problem, but I just wanted to ask here if I'm missing something before I open source it as a tiny lib.

I retract this. See #797.