-
Notifications
You must be signed in to change notification settings - Fork 40
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
Conversation
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.
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 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)) => { |
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.
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); |
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.
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.
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. |
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? |
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 About the lens having an update function, that could be neat, but I would prefer to handle that separately. Thanks. |
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 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. |
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.
The
|
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.
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.
examples/render/renderEntry.re
Outdated
|
||
module CounterProvider = { | ||
let lens = Reductive.Lens.make(a => a.counter); | ||
let make = Reductive.LensedProvider.createMake(store, lens); |
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.
With my proposal this would just be a Provider - no need for a LensedProvider module...
...component, | ||
render: _self => | ||
<div> | ||
<CounterProvider component=CounterComponent.make /> |
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.
... and this would look like:
<CounterProvider component=CounterComponent.make /> | |
<CounterProvider component=Reductive.lensedComponent(CounterComponent.make, s => s.counter) /> |
Yeah, I tried that and got |
There's an issue with refmt - you can write this way, but refmt here is not stable.
|
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 |
@nlfiedler: in your latest code the lense is wrapped inside the provider, which gets re-rendered on every state change since its internal state @rickyvetter: Why does the lense fits better as separate component. Isn't it just a provider that narrows down the state? |
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. |
@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. |
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.
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, |
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.
As @ambientlight recommended this should really be referential comparison. This will run a quite expensive comparison on deeply nested data structures.
oldSelf.state.reductiveState != newSelf.state.reductiveState, | |
oldSelf.state.reductiveState !== newSelf.state.reductiveState, |
Yeah if you could squash the wip commits that would be great! |
@rickyvetter, @nlfiedler: I was thinking maybe separate components would actually look more neat. |
@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)
}; |
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.
@rickyvetter I squashed the recent changes, up to the point of the merge from master. @ambientlight Thanks for the code, this works splendidly. |
Looks good. Thanks all. And thanks for making the extra effort to make this a clean and non-breaking addition. |
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.