[Edit] "Ready for the real world?" is just a catch phrase - don't take that too serious :wink: in particular I do not mean "production ready" or something
Franky, I'm a bit concerned that most of the Crank demos I've seen in so far, have only covered use cases that are obviously well suited for Crank.
As by the end of the day, Crank will have to prove its power in ALL use cases and not just the well suited ones, please allow me to start a little series of small React demos that I think are a bit more challenging for Crank.
Would be great if some of you guys like to meet new challenges and try to find a good way how to implement those short examples with Crank.
Important: It's really NOT necessary to provide an actual running Crank demo - just think of some useful helper functions (without implementing them) or desired API changes for Crank if you like and provide some short pseudocode - it's not important whether everything is 100% correct or not, it's just about the patterns.
Also, it would be nice if the presumable code size of the Crank version (without the presumable code size of your imaginary helper functions) is more or less comparable with the code size of the React version.
The first example is a little "Metronome" (tick ... tock ...tick ... tock...) demo that I have already mentioned in some other issue (and due to issue #43 I assume that this might be a tough nut):
https://codesandbox.io/s/jolly-bash-t511f?file=/src/index.js
Thanks a lot in advance and good luck.
This could probably use some polish, but I made a sorta-React-hooks-like useInterval and implemented a metronome: https://gist.github.com/lynlevenick/12095e0d5b8212f16f165abb24edc5aa
I then went on to implement useEffect so I wouldn't have to rewrite the setup and teardown, if I were to use this more in the future: https://gist.github.com/lynlevenick/e6e8a543f1fdae699cab2a12b0a5444b
edit: code moved to gists to not fill up the page
Which IMO makes a decent interface to build on top of (It's the one from React, with a couple tweaks to fit to crank), albeit still requiring explicit cleanup.
I guess explicit ways to handle life cycles would be useful for examples like this one - this.useEffect automatically managing cleanup and returning a generator like the one my code above creates, for example, would be really nice and still allow you to build calls like useInterval(this, () => /* ticking */); which would call the appropriate methods, or similar. Most of my code in my example is setting up and tearing down the life cycles, which is unfortunate in comparison to the React.
The following is IMHO not a good solution for Crank - I just mention it for inspiration (just because it is quite concise):
If you add some special methods to the API of this (aka. Context) then something like the following is possible - yet like said not necessarily a nice solution (maybe even the opposite of what Crank is actually trying to do :wink:):
function* Metronome() {
const
props = useProps(this, { bpm: 60 }), // props is mutating (like Vue3), 60 bpm is default
[tock, setTock] = useValue(this, false) // tock is a mutating object (like Vue3 refs)
useInterval(this,
() => setTock(it => !it),
() => 60000 / props.bpm)
while (true) {
yield <div>Metronome: {props.bpm} bpm => {tock.value ? 'Tock' : 'Tick'}</div>
}
}
I suppose I'm suggesting something like:
function useInterval(component, callback, delay?: number) {
// useEffect registers on "this" (here, component) and does cleanup when unmounted
// like event listeners should
return component.useEffect(
(delay?: number) => {
if (delay != null) {
const interval = setInterval(callback, delay);
return () => clearInterval(interval);
}
},
[delay]
);
}
function* Metronome({ bpm }) {
const tick = false;
const timer = useInterval(this, () => {
tick = !tick;
this.refresh();
});
for ({ bpm } of this) {
timer.next([60000 / bpm]);
yield <div>Metronome: {bpm} bpm => {tick.value ? 'Tick' : 'Tock'}</div>;
}
}
If we're on the topic of theoretical api changes, what do you guys think about a more "crank-like" approach defined in this draft PR
@monodop Would it be possible to show us a few lines of pseudocode how the implementation of this Metronome component could look like using the enhancements of that PR? Thanks :)
@monodop I'm not sure I like the API - I like the way it's turned into a second generator that runs alongside the rendering, but the way the effect has to be controlled by mutating state or mutating the props (which you probably don't control) makes me worry. I suppose mutating state externally and then calling this.refresh() is probably fine? I also have a vague worry about type safety with an API in that style, but nothing concrete.
I'm concerned about the amount of glue code potentially written to achieve effects like the changing interval used in the metronome.
Here's what I came up with as an example for the metrinome example (untested). I conceded it might not be as pretty as hooks, but it's a different idea at least 🙂
function* Metrinome({bpm}) {
const tick = this.addExtension(function* (_, state) {
let prevBpm;
let timer;
state.value = false;
try {
for ({bpm} of this) {
if (bpm != prevBpm) {
prevBpm = bpm;
clearInterval(timer);
timer = setInterval(() => {
state.value = !state.value;
this.refresh();
}, 60000 / bpm);
}
}
}
finally {
clearInterval(timer);
}
});
for ({bpm} of this) {
yield <div>Metronome: {bpm} bpm => {tick.value ? 'Tick' : 'Tock'}</div>;
}
}
In retrospect this probably isn't any better than just dropping that logic inline, but at least with this you could theoretically just write a setInterval extension and use that.
Edit: in further retrospect I hate this example. I'll keep messing around with this
So I think here's where you could potentially benefit. If you were to first create an extension for setInterval and use that in your function, I think you could get a better looking component. For example:
function setIntervalExtension(cb, ms) {
return function* extension(props, state) {
let timer = setInterval(() => {
cb();
}, ms);
let cleared = false;
function stop() {
cleared = true;
}
function updateInterval(ms) {
clearInterval(timer);
timer = setInterval(() => {
cb();
}, ms);
}
try {
for (props of this) {
if (cleared)
break;
yield { stop, updateInterval };
}
}
finally {
clearInterval(timer);
}
}
}
Context.prototype.setInterval = function setInterval(cb, ms) {
return this.addExtension(setIntervalExtension(cb, ms)).value;
};
And then to use it:
function* Metrinome(this, {bpm}) {
let prevBpm;
let tick = false;
const onTick = () => {
tick = !tick;
this.refresh();
}
let { updateInterval } = this.setInterval(onTick, 60000 / bpm);
for ({bpm} of this) {
if (bpm != prevBpm) {
updateInterval(60000 / bpm);
prevBpm = bpm;
}
yield <div>Metronome: {bpm} bpm => {tick ? 'Tick' : 'Tock'}</div>;
}
}
@monodop thoughts on my comment above with, to use your terminology, a hypothetical useEffect extension? It unifies the setup/teardown process that will commonly get written, so writing effects/extensions/etc is simpler. I feel like we have the same approach with slightly varying abstractions.
@lynlevenick @monodop
Thanks for your suggestions. I see some really interesting ideas there. Unfortunately, I'm a bit busy now, so please don't be angry at me if my feedback will take until tomorrow. Just one litte thing @monodop: In your proposal you are extending the Context prototype. Unfortunatly it will not be possible to type that properly with TypeScript. But that's not a big deal. Why not just switching from this.setInterval(onTick, 60000 / bpm) to useInterval(this, onTick, 60000 / bpm) (or some other function name)? This would be perfectly typeable and the Context prototype could stay as is and moreover this would prevent possible name clashes between different extensions ...
@lynlevenick You could easily make a useEffect extension if you wanted. And I would agree that I think our approaches are pretty similar. I just brought mine up because it's pretty similar to the concepts already introduced by crank. It's not necessarily a better abstraction. Though I think there are some interesting possibilities with async generator extensions that I'm probably not smart enough to come up with.
@mcjazzyfunky Yep, didn't even think of that but yeah that would work. I'm not exactly a fan of extending prototypes either. However, you couldn't call it setInterval because that would override the existing global function called setInterval
However, you couldn't call it setInterval because that would override the existing global function called setInterval
Thanks, have seen that 2 minutes ago and changed it... :)
When you say "Ready for the real world?" what exactly do you mean? A metronome example app doesn't really reflect real-world usage. Are you interested in reusable logic with setup and teardown steps (like hooks)?
The React community faced pretty much the same problem over the last few years, since the imperative execution model provided by class components operated pretty much the same way Crank's imperative model works (sans async support). So you can apply the same broad classes of solutions:
One of the worst things about hooks is that they're magic. They operate under a different mental model than plain JavaScript which is opaque to the end user. You need to learn to "think in terms of hooks" instead of thinking in terms of JavaScript.
Exposing the component lifecycle (e.g. via the existing EventTarget system) would make it trivial to develop helper methods capable of cleaning up after themselves. You don't need anything as awkward as React hooks to do it. But exposing the component lifecycle has its own disadvantages.
@wmadden
Thanks for those great demos - they are really helpful in so many ways...
When you say "Ready for the real world?" what exactly do you mean?
Ha ha, okay, so I've updated the description above regarding this a couple of minutes ago: It's just a silli catchphrase - not really important.
One impulse, why I've opened this issue was that in the real world most of the time you have components with a lot of props and each single prop can change almost at any time. Nearly none of the current Crank examples (at least of those I've seen) cover this "real world" pattern (or at least not in a satisfying manner). And there also a lot of other often reoccurring "real world" patterns which are also not covered yet (just because the project is quite new, of course).
Are you interested in reusable logic with setup and teardown steps (like hooks)?
By the end of the day this is exactly what the whole thing is all about.
It's perfectly fine to dislike the React hook API, but honestly, really nobody can seriously deny that React hooks are extemely powerful in regard of resusability, side effect abstraction, conciseness and making the code (at least a bit) more declarative.
Every UI library that has the serious intention to compete with React has to provide a really sophisticated and coequal alternative to React hooks (it can look completely different, but it should cover the same purposes coequally). And this is far more challenging that most folks seem to presume.
There's a good argument to be made for Crank here that you can implement this without needing an extra library
The fact that something like this is possible in Crank is one of the major reasons why Crank aroused my curiosity. But at the end of the day you will always use a lot of helper functions independent whether they are part of your UI library's core or not. Allow me to ask the following teasing question: At work, would you prefer to perform a code review of the React version or the Crank version of this tiny demo? Especially as with the latter you have to carefully check whether all bpm prop changes are detected correctly and whether all timeouts are really cleared properly (while in the React version you get that for free).
[...] but the same pattern works for higher order components
Yes, but as you know HOCs have a lot of downsides that's why the React team was happy to get rid of them (more or less at least). And btw, I think it's fair to say that using extra components just to handle some 'setInterval' based side effects seems a but suboptimal, doesn't it?
Timer code extracted to reusable helper function aware of component lifecycle - this requires the framework to expose the component lifecycle, imitated in this example
This was exactly what I hoped the discussion would lead to :wink: :
This makeTimer function is actually an attempt to find some counterpart to React hooks, btw: every function of type someFunction(context, ....) could be (similar things have been done by @lynlevenick and @monodop).
Now the question is: What Crank core API changes do you need to implement that makeTimer function 100% the way you would like to? That's one target of this issue. If for example this (aka Context) would have a methods addUnmountListener(callback) then your makeTimer could handle that clearTimeout thing on its own. But is that a good idea (you've mentioned some "disadvantages", so maybe it's not)? Would it be better that this makeTimer function is somehow based on a generator to make it a bit more Crankish as @monodop and @lynlevenick proposed in their comments above? These are questions that have really to be answered before Crank will be production-ready someday...
Detecting prop changes imperatively is annoying (maybe that's one of the reasons why you have not handled that in your third demo - instead clear/setInterval will be called at each update), is there a simpler way to handle this? And not only props are changing ... is there also a simpler way to handle changes of other values as well?
Before we find answers, we have to ask questions...
One of the worst things about hooks is that they're magic.
That's actually just a matter of taste. I personally prefer those solutions that causes me the least trouble (independent of magic or not).
To show what I mean: Here's the same example implemented with a little toy library that does not have any magic at all. The hooks are equally powerful like React hooks (yet completely written in userland). It does not even need generators to work. The base concept is extremely simple.
But still it really stinks compared to React as it is way more cumbersome than React's API and it's really less joy to work with it. Just as an example that "no magic" does not necesssarily has to be an advantage :)
https://codesandbox.io/s/naughty-herschel-7tdw4?file=/src/index.js (btw: It's of course not a coincident that this looks similar to my code snippet above)
@lynlevenick @monodop @wmadden
Thanks for all that feedback, please allow me to summarize now:
:question: Do you agree, that this is a good way to implement the metronome demo with Crank.js, assuming a future version of Crank will allow that this makeTimer function will somehow be allowed to cleanup its own resources on unmount and assuming that the timer.setInterval method will check on its own whether the delay value has changed or not?
BTW: You are not allowed to say "no" as the following is basically the same as you all have proposed above :wink:
function* Metronome({ bpm = 60 }) {
let tock = false
const toggle = () => {
tock = !tock
this.refresh()
}
const timer = makeTimer(this, toggle, 60000 / bpm)
for (const { bpm = 60 } of this) {
timer.setInterval(60000 / bpm)
yield (
<div>
Metronome: {bpm} bpm => {tock ? 'Tock' : 'Tick'}
</div>
)
}
}
I personally like the idea that functions like this makeTime are implemented somehow by using generators internally (which will automatically imply the lifecycle behavior) as @monodop and @lynlevenick have proposed, it's twisting my tiny little brain :brain: :wink:, but if it can be implemented that way, why not? :)
Using this.useEffect(...) thing as suggested somewhere above would also work ...
But this is not in the scope of this issue and will be evaluated somewhere else.
:question: But in either case do you agree that functions like makeTimer/useTimer/whatever (I don't want to use the evil h**k word :wink: ) are a useful pattern in Crank (in the non-magical version with passing this/Context as first argument)?
Detecting prop changes imperatively is annoying (maybe that's one of the reasons why you have not handled that in your third demo - instead clear/setInterval will be called at each update), is there a simpler way to handle this? And not only props are changing ... is there also a simpler way to handle changes of other values as well?
Let's separate these two problems. There a lot of established solutions to the problem of detecting which props have changed and this can be handled in user space. Let's ignore this for the purposes of this discussion and focus on how to hook into the component lifecycle.
I personally like the idea that functions like this makeTime are implemented somehow by using generators internally (which will automatically imply the lifecycle behavior)
This is not true, using generators is not relevant to this problem (and my example makeTimer() is not a generator). Calling one generator from another won't make the second one return when the first one does. Crank hooks up this behavior for components, it's not part of the language.
In my example I rely on a fictional "component.unmount" event from the framework:
function makeTimer(context, callback, interval) {
// ...
context.addEventListener("component.unmount", () => { ... });
// ...
}
// ...
} finally {
// Imagine this was dipatched by the framework instead of my application code
this.dispatchEvent(new CustomEvent("component.unmount"));
}
❓ Do you agree, that this is a good way to implement the metronome demo with Crank.js ...
❓ But in either case do you agree that functions like makeTimer/useTimer/whatever (I don't want to use the evil h**k word 😉 ) are a useful pattern in Crank (in the non-magical version with passing this/Context as first argument)?
I agree that in order to build reusable component logic which is dependent on component lifecycle without adding extra nodes to the tree (HoCs, wrapper components) it will be necessary for the framework to expose the lifecycle events somehow.
To be clear, to talk about "adding hooks" is not the same as saying "we need access to the component lifecycle". Hooks rely internally on React indicating to them when to perform cleanup. Most of the visible surface area of hooks is the magic API where stuff gets called at the right time, and state is hidden in the execution context of the framework somehow.
We don't need the hooks API to use the component lifecycle, and its hidden behavior seems contrary to Crank's straightforward "Just JavaScript" philosophy - which I think is what we all find so appealing about it.
@wmadden Sorry, but there seems to be a little misunderstanding: I do not and I have never and I will never propose to add some magical React like hook function's to Crank's core (that would be a really stupid idea for Crank, for sure). What I've said is that at the end of the day it will be necessary to - like you've called it - "expose the lifecycle events somehow". After that it's possible to implement function like your makeTimer(this, ....) function in userland. And all I claim is only that such functions (all written in userland) which have a Context object as (let's say by convention) first argument are very, very useful and will be one way (maybe there are better ways - who knows?) to achieve the same power (like I've called it) "in regard of resusability, side effect abstraction, conciseness and making the code (at least a bit) more declarative", same purposes as React hooks.
Whether you name such functions makeTimer(this, ...) or useTimer(this, ...) or withMousePos(this) or useMousePos(this) or mousePos(this) is just a matter of taste ... I personally like the naming pattern useWhatever(this,....) and I also have no problem with calling those functions hooks even if the (magicless) concept is different to the one React is using.
Actually, I've even shown a demo of a little toy library that uses such magicless functions with the naming pattern useWhatever(this, ...) which are all completely written in userland (the core does not know anything about them) and are mostly harmless.
Regarding how functions like makeTimer(this, ...) will be allowed to have access to component lifecycle events, was - like I've said - not necessarily meant to be in the scope of this issue (at least not in all details). I'm sure this will be covered soon somewhere else (because it's quite important).
BTW: I've never said that functions like makeTime should be generators, I've just used the term "are implemented somehow by using generators internally" (actually you've even quoted me on that) - both other guys proposed internal (!) generators for that - if this is working? I do not know, like said all those generators are twisting my tiny little brain a bit too much :-)
Okay, again, thank you very much to everybody.
It was a very helpful that you have shared your ideas and experiences.
I will close this issue now.
Nevertheless, of course, feel free to add additional comments if you like.
And Yes, this is without hooks
https://codesandbox.io/s/silly-panini-3l1jn?file=/src/index.js
Yield new before the component which is going to get a new Prop value - line number 27.
Edited:
This is without diff yield
https://codesandbox.io/s/sharp-proskuriakova-n5ld2?file=/src/index.js
Most helpful comment
When you say "Ready for the real world?" what exactly do you mean? A metronome example app doesn't really reflect real-world usage. Are you interested in reusable logic with setup and teardown steps (like hooks)?
The React community faced pretty much the same problem over the last few years, since the imperative execution model provided by class components operated pretty much the same way Crank's imperative model works (sans async support). So you can apply the same broad classes of solutions:
One of the worst things about hooks is that they're magic. They operate under a different mental model than plain JavaScript which is opaque to the end user. You need to learn to "think in terms of hooks" instead of thinking in terms of JavaScript.
Exposing the component lifecycle (e.g. via the existing
EventTargetsystem) would make it trivial to develop helper methods capable of cleaning up after themselves. You don't need anything as awkward as React hooks to do it. But exposing the component lifecycle has its own disadvantages.