Skip to content
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

Introduce Lens to allow for conditional updates #35

Merged
merged 3 commits into from
Nov 27, 2018
Merged

Introduce Lens to allow for conditional updates #35

merged 3 commits into from
Nov 27, 2018

Conversation

nlfiedler
Copy link
Contributor

The Lens is used to modify the state into the shape that the component
requires, which allows Reductive.Provider to implement shouldUpdate(). This
avoids needless updates when another part of the application state changes.
You can observe the change using React devtools "highlight updates."

Introduces a new type, Lens, and all users of Provider must now provide an
implementation of this type as an argument to createMake(). Wanted to make
this argument optional, but the default would return a 'state instead of a
'lensed, and I didn't know how to resolve that.

This change includes a new example specifically constructed to demonstrate
the problem, or lack thereof now that Lens is in use by Provider.

The Lens is used to modify the state into the shape that the component
requires, which allows Reductive.Provider to implement shouldUpdate(). This
avoids needless updates when another part of the application state changes.
You can observe the change using React devtools "highlight updates."

Introduces a new type, Lens, and all users of Provider must now provide an
implementation of this type as an argument to createMake(). Wanted to make
this argument optional, but the default would return a 'state instead of a
'lensed, and I didn't know how to resolve that.

This change includes a new example specifically constructed to demonstrate
the problem, or lack thereof now that Lens is in use by Provider.
Copy link
Owner

@rickyvetter rickyvetter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the changes are pretty aggressive. Is it possible to get an API that isn't breaking?

src/reductive.re Outdated
@@ -49,40 +55,40 @@ module Provider = {
type action =
| UpdateState
| AddListener(action => unit);
let createMake = (~name="Provider", store: Store.t('action, 'state)) => {
let createMake = (~name="Provider", store: Store.t('action, 'state), lens: Lens.t('state, 'lensed)) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty major breaking change. Why not make this an optional argument that defaults to identity? I think this would provide the same behavior as an option for more complex cases without ruining existing code.

src/reductive.re Outdated
module Lens = {
type t('a, 'b) = 'a => 'b;
let make = getter => getter;
let view = (l, a) => l(a);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lenses typically have update functions as well. I think this would probably be helpful for writing large reducers. Not blocking this PR of course, but throwing it out there as something to look into.

@rickyvetter
Copy link
Owner

I see your comment on #27 and then was reading up on this issue and I now see why you're running into trouble with this. Let me think about this a bit more.

https://stackoverflow.com/questions/48363986/force-a-broader-type-for-optional-argument-with-more-restrictive-default-value

@rickyvetter
Copy link
Owner

I think lenses might fit better as a separate dedicated component, or maybe even as a hook in the new React api. Have you looked into making it as a separate component?

@nlfiedler
Copy link
Contributor Author

nlfiedler commented Nov 18, 2018

I gave this a good deal of thought and could not manage to make this change in a way that satisfied me. It works, it's a bunch of wretched duplicate code, but it works, and the lens is now optional. I tried making an optional "getState" function, but ran into the same problem as before, with trying to make the lens optional. I also wondered if there could have been a thin provider that would be wrapped by the plain Provider, but that seemed like it would be difficult to get away with, and likely end up being just as much code. I'm open to any ideas that will make this better.

About the lens having an update function, that could be neat, but I would prefer to handle that separately. Thanks.

@rickyvetter
Copy link
Owner

rickyvetter commented Nov 18, 2018

So it seems like a lot of this duplication is a result of shoe-horning a solution into an existing API that Reason has some trouble typing. I think there are a few ways we can solve this in a completely different way that plays nicely with everything involved. How would you feel about a function like this:

let lensedComponent = (make, get, ~state, ~dispatch, children) => {
  let state = get(state);
  let baseComponent = make(~state, ~dispatch, children);
  {
    ...baseComponent,
    ReasonReact.retainedProps: state,
    shouldUpdate:
      (
        {
          oldSelf: {retainedProps: oldState},
          newSelf: {retainedProps: newState},
        } as total,
      ) =>
      oldState === newState ?
        false : baseComponent.ReasonReact.shouldUpdate(total),
  };
};

The pros here: no code duplication, no unintuitive name change to get lensed behavior, no breaking change, minimal api addition.

The cons: the api kinda looks like it'll work for any component, but actually needs the exact reductive component api for the make function it wraps. It does have a constant-time overhead on each call because it's spreading the baseComponent record.

There are a few other ways to do this as well, but this is the one I can think of now that has my personal favorite set of tradeoffs. Interested to hear if it'll work for your use case and how you feel about it.

NOTE: didn't actually run this code. Just wrote it up and typechecked it - but I think it should work.

@nlfiedler
Copy link
Contributor Author

Will that operate in the same manner as the lensed provider does now? Also, could I get your help with the type signatures? I can't seem to get it right.

let lensedComponent =
    (
      make:
        ('lensed, 'action => unit, array(ReasonReact.reactElement)) =>
        ReasonReact.component('a, 'b, 'c),
      get: 'state => 'lensed,
      ~state: 'state,
      ~dispatch: 'action => unit,
      children: array(ReasonReact.reactElement),
    )
    : ReasonReact.component('a, 'b, 'c) => {
  let state: 'lensed = get(state);
  let baseComponent = make(~state, ~dispatch, children);
  /* ... */
};

The ~state in the last line has an error:

  119 ┆   : ReasonReact.component('a, 'b, 'c) => {
  120 ┆ let state: 'lensed = get(state);
  121 ┆ let baseComponent = make(~state, ~dispatch, children);
  122 ┆ {
  123 ┆   ...baseComponent,

  The function applied to this argument has type
    ('action => unit, array(ReasonReact.reactElement)) =>
    ReasonReact.componentSpec('a, 'a, 'b, 'b, 'c)
This argument cannot be applied with label ~state

Copy link
Owner

@rickyvetter rickyvetter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will that operate in the same manner as the lensed provider does now?

It's a different api but it should have the same effect.

could I get your help with the type signatures

It should be make: (~state: 'lensed, ~dispatch: 'action => unit, array(ReasonReact.reactElement)). You need the named args.


module CounterProvider = {
let lens = Reductive.Lens.make(a => a.counter);
let make = Reductive.LensedProvider.createMake(store, lens);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my proposal this would just be a Provider - no need for a LensedProvider module...

...component,
render: _self =>
<div>
<CounterProvider component=CounterComponent.make />
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... and this would look like:

Suggested change
<CounterProvider component=CounterComponent.make />
<CounterProvider component=Reductive.lensedComponent(CounterComponent.make, s => s.counter) />

@nlfiedler
Copy link
Contributor Author

Yeah, I tried that and got Error: Syntax error: Labels are not allowed inside a tuple not expected. I'll keep playing with it.

@rickyvetter
Copy link
Owner

rickyvetter commented Nov 20, 2018

There's an issue with refmt - you can write this way, but refmt here is not stable.

let lensedComponent =
    (
      make:
        ((
          ~state: 'lensed,
          ~dispatch: 'action => unit,
          array(ReasonReact.reactElement)
        ) =>
        ReasonReact.component('a, 'b, 'c)),
      get: 'state => 'lensed,
      ~state: 'state,
      ~dispatch: 'action => unit,
      children: array(ReasonReact.reactElement),
    )
    : ReasonReact.component('a, 'b, 'c) => {
  let state: 'lensed = get(state);
  let baseComponent = make(~state, ~dispatch, children);
  {
    ...baseComponent,
    ReasonReact.retainedProps: state,
    shouldUpdate:
      (
        {
          oldSelf: {retainedProps: oldState},
          newSelf: {retainedProps: newState},
        } as total,
      ) =>
      oldState === newState ?
        false : baseComponent.ReasonReact.shouldUpdate(total),
  };
};

reasonml/reason#2271

@nlfiedler
Copy link
Contributor Author

Terrific, thanks for tracking down that issue with the parsing, good to know it's fixed. I've adjusted the types in the function signature to get things compiling, and had to change the components in question to ...WithRetainedProps and add the retainedProps field. So far so good, but the React "highlight updates" shows that the entire application is rendering if any change is made. I've pushed the latest code for comparison. I'll continue to look at this...

@ambientlight
Copy link
Contributor

@nlfiedler: in your latest code the lense is wrapped inside the provider, which gets re-rendered on every state change since its internal state type state('reductiveState) = { reductiveState: option('reductiveState), unsubscribe: option(unit => unit)}; changes on every update.

@rickyvetter: Why does the lense fits better as separate component. Isn't it just a provider that narrows down the state?

@ambientlight
Copy link
Contributor

ambientlight commented Nov 25, 2018

What about this kind of provider. Doesn't break existing functionality.

module Provider = {
  type state('reductiveState) = {
    reductiveState: option('reductiveState),
    unsubscribe: option(unit => unit)
  };

  type action =
    | UpdateState
    | AddListener(action => unit);
  
  let createLenseMake = (~name="Lense", ~lense:'state => 'lense, store: Store.t('action, 'state)) => {
    let innerComponent = ReasonReact.reducerComponent(name);
    
    let make = (~component: (~state: 'lense, ~dispatch: 'action => unit, array(ReasonReact.reactElement)) => ReasonReact.component('a, 'b, 'c), _children: array(ReasonReact.reactElement)): 
    ReasonReact.component(state('lense), ReasonReact.noRetainedProps, action) => {
      ...innerComponent,
      
      initialState: () => {
        reductiveState: Some(lense(Store.getState(store))),
        unsubscribe: None
      },

      reducer: (action, state) =>
        switch (action) {
        | AddListener(send) =>
          ReasonReact.Update({
            unsubscribe: Some(Store.subscribe(store, (_) => send(UpdateState))),
            reductiveState: Some(lense(Store.getState(store)))
          })
        | UpdateState =>
          ReasonReact.Update({
            ...state,
            reductiveState: Some(lense(Store.getState(store)))
          })
      },
      
      didMount: ({send}) => send(AddListener(send)),

      willUnmount: ({state}) =>
        switch (state.unsubscribe) {
        | Some(unsubscribe) => unsubscribe()
        | None => ()
        },

      shouldUpdate: ({oldSelf, newSelf}) => {
        oldSelf.state.reductiveState !== newSelf.state.reductiveState;
      },

      render: ({state}) =>
        switch (state.reductiveState) {
        | None => ReasonReact.null
        | Some(state) =>
          ReasonReact.element(
            component(~state, ~dispatch=Store.dispatch(store), [||])
          )
        }
    };
    
    make;
  };

  let createMake = (~name="Provider", store: Store.t('action, 'state)) => createLenseMake(~name, ~lense=s => s, store)
};
let make = Reductive.Provider.createMake(store);
let make = Reductive.Provider.createLenseMake(~lense=store=>store.content, store);

This is somehow similar to what was originally proposed here, though I though referential comparison should be used, comparing complex substrate structurally might get expensive.

@nlfiedler
Copy link
Contributor Author

@ambientlight That was awesome, thank you. A simple solution that resolves all of the issues, I believe. The code now works as intended, rendering only the components that actually changed, and is backward compatible. Thanks for the code and suggestion.

@rickyvetter Please let me know what you think of this latest code. Also, I'm happy to squash these commits, if so desired. The work in progress is kinda of interesting, but also messy.

Copy link
Owner

@rickyvetter rickyvetter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think this implementation is pretty good. I originally wanted the lensing feature to be completely separate because you might be able to use many different lenses with a single subscriber. I'm not actually confident that would work though and I'm happy with adding this api.

src/reductive.re Outdated
@@ -92,6 +97,8 @@ module Provider = {
| Some(unsubscribe) => unsubscribe()
| None => ()
},
shouldUpdate: ({oldSelf, newSelf}) =>
oldSelf.state.reductiveState != newSelf.state.reductiveState,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @ambientlight recommended this should really be referential comparison. This will run a quite expensive comparison on deeply nested data structures.

Suggested change
oldSelf.state.reductiveState != newSelf.state.reductiveState,
oldSelf.state.reductiveState !== newSelf.state.reductiveState,

@rickyvetter
Copy link
Owner

Yeah if you could squash the wip commits that would be great!

@ambientlight
Copy link
Contributor

ambientlight commented Nov 26, 2018

@rickyvetter, @nlfiedler: I was thinking maybe separate components would actually look more neat.
Let me quickly throw an implementation (with no code duplication)

@ambientlight
Copy link
Contributor

@rickyvetter, @nlfiedler: How about this?

module Lense = {
  type state('reductiveState) = {
    reductiveState: option('reductiveState),
    unsubscribe: option(unit => unit)
  };

  type action =
    | UpdateState
    | AddListener(action => unit);
  
  let createMake = (~name="Lense", ~lense:'state => 'lense, store: Store.t('action, 'state)) => {
    let innerComponent = ReasonReact.reducerComponent(name);
    
    let make = (~component: (~state: 'lense, ~dispatch: 'action => unit, array(ReasonReact.reactElement)) => ReasonReact.component('a, 'b, 'c), _children: array(ReasonReact.reactElement)): 
    ReasonReact.component(state('lense), ReasonReact.noRetainedProps, action) => {
      ...innerComponent,
      
      initialState: () => {
        reductiveState: Some(lense(Store.getState(store))),
        unsubscribe: None
      },

      reducer: (action, state) =>
        switch (action) {
        | AddListener(send) =>
          ReasonReact.Update({
            unsubscribe: Some(Store.subscribe(store, (_) => send(UpdateState))),
            reductiveState: Some(lense(Store.getState(store)))
          })
        | UpdateState =>
          ReasonReact.Update({
            ...state,
            reductiveState: Some(lense(Store.getState(store)))
          })
      },
      
      didMount: ({send}) => send(AddListener(send)),

      willUnmount: ({state}) =>
        switch (state.unsubscribe) {
        | Some(unsubscribe) => unsubscribe()
        | None => ()
        },

      shouldUpdate: ({oldSelf, newSelf}) => 
        oldSelf.state.reductiveState !== newSelf.state.reductiveState
      ,

      render: ({state}) =>
        switch (state.reductiveState) {
        | None => ReasonReact.null
        | Some(state) =>
          ReasonReact.element(
            component(~state, ~dispatch=Store.dispatch(store), [||])
          )
        }
    };
    
    make;
  };
};

module Provider {
  type state('reductiveState) = Lense.state('reductiveState);
  type action = Lense.action;
  let createMake = (~name="Provider", store: Store.t('action, 'state)) => Lense.createMake(~name, ~lense=s => s, store)
};

@rickyvetter
Copy link
Owner

Yeah I have a preference for the separate modules definitely.

Using code from Taras Vozniuk, the Provider component now delegates to a new
Lense component, which contains the bulk of the original Provider code. The
"new" Provider implements a default identity function for the lense.

This approach retains the desired behavior of rendering only the changed
parts of the application, as well as remaining backward compatible.

Seems that refmt changed some parentheses to curly braces.
@nlfiedler
Copy link
Contributor Author

@rickyvetter I squashed the recent changes, up to the point of the merge from master.

@ambientlight Thanks for the code, this works splendidly.

@rickyvetter
Copy link
Owner

Looks good. Thanks all. And thanks for making the extra effort to make this a clean and non-breaking addition.

@rickyvetter rickyvetter merged commit d9903eb into rickyvetter:master Nov 27, 2018
@nlfiedler nlfiedler deleted the render branch November 29, 2018 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants