-
Notifications
You must be signed in to change notification settings - Fork 84
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
Reactive Magic #142
Comments
Hello @ccorcos. Thank you for sharing the library. That is pretty cool 😄 Back when I wrote Flyd I actually toyed with a similar idea myself. I agree with you that while it looks very cool it can seem a bit too magical.
I'd like to hear more about that and your experiences. As far as I can see it actually doesn't offer that much. The example you posted could be created with const x = stream(1);
const y = stream(1);
const z = lift((x, y) => x + y, x, y); And in my opinion the latter code has the advantage that the logic (the addition) is a "plain" function. This has the benefit that I could just as well throw in const x = stream(1);
const y = stream(1);
const z = lift(R.add, x, y); Again, thanks for sharing this. I'd love to hear more 😄 |
Check out the example app and specifically the React component subclass. I totally agree that there there are benefits to taking a strictly functional and idiomatic approach to programming. But also think that I often spend too much time thinking about the architecture (like this project built on Snabbdom) rather than building stuff. In terms of building things fast, you can create global singleton store like this store that holds the mouse position: const MouseStore = Store({ x: 0, y: 0 });
document.addEventListener("mousemove", function(event) {
MouseStore.x = event.clientX;
MouseStore.y = event.clientY;
}); And then you can use this store magically reactively in any of your components.
You can also create local state by instantiating a store inside the component itself. class Counter extends Component {
store = Store({ count: 0 });
increment = () => {
this.store.count += 1;
};
decrement = () => {
this.store.count -= 1;
};
view() {
return (
<div>
<button onClick={this.decrement}>{"-"}</button>
<span>{this.store.count}</span>
<button onClick={this.increment}>{"+"}</button>
</div>
);
}
} And if you want to get fancy, you can derive values as well to create new stores: const SizeStore = Store({
height: window.innerHeight,
width: window.innerWidth
});
window.onresize = function() {
SizeStore.height = window.innerHeight;
SizeStore.width = window.innerWidth;
};
const InfoStore = Store({
x: Derive(() => MouseStore.x / SizeStore.width),
y: Derive(() => MouseStore.y / SizeStore.height)
});
class Info extends Component {
view() {
return (
<ul>
<li>x: {InfoStore.x}</li>
<li>y: {InfoStore.y}</li>
</ul>
);
}
} And thats basically it! Create stores, import what you need, use it wherever you need it, and everything just magically works -- you don't even have to think! I just started a new job at Notion and their entire architecture works in a very similar way. It's a very complex piece of software but building new editor blocks is surprisingly easy. |
@ccorcos Sorry for taking so long to reply to this. I totally forgot about it 😭 Thank you for linking to arbol. That's a pretty interesting idea.
I definitely agree with this. It's certainly no fun to write functional code when it comes at the cost of ease and productivity. A functional framework should offer an experience that is actually better in the real world. Not just an experience that satisfies some strict property. One thing I really like about your approach is how you can insert a But, I still don't see the point of the magic in the + const div = (a, b) => a / b;
const InfoStore = Store({
- x: Derive(() => MouseStore.x / SizeStore.width),
- y: Derive(() => MouseStore.y / SizeStore.height)
+ x: lift(divide, MouseStore.x, SizeStore.width),
+ y: lift(divide, MouseStore.y, SizeStore.height)
}); To me, that is just as good and a lot simpler since there is no magic. And, now that we're sharing libraries I'd love to hear what you think about this framework that I'm working on called Turbine. I apologize for bringing up my own project in a thread about yours. But, it actually has some similarities to Reactive Magic. Namely that we're also inserting reactive values directly into the view. In addition to that, we also use the reactive values to completely avoid the overhead of virtual dom diffing. The idea is basically that since the reactive values tell us exactly when the state change we can use that to know exactly when the DOM has to change. There is no need for a diffing process to figure it out. I wrote a bit more about that here. Again, I'd love to hear your opinion. |
i was gonna mention @adamhaile's surplus but looks like that's already on the radar in the linked thread :) i have a demo [1] for domvm that uses flyd's streams embeded in vtree templates as a form of granular redraw optimization, even though domvm is a traditional vdom lib. it works pretty well [2] considering it's just bolted on :) EDIT: heh, now that i looked at arbol, it looks almost the same as my demo. [1] https://github.com/leeoniya/domvm/blob/2.x-dev/demos/streams.html |
Lots of interesting stuff here! Thanks, @leeoniya, for pointing me to the discussion. Yeah, Reactive Magic definitely has some similarities to (S)[https://github.com/adamhaile/S]. S has both ways of defining dependencies: either automatically via S(() => ... a() ... b() ...) or explicitly via S.on([a, b], () => ...). Chet, I've got an S -> React binding that's a lot like your Component. I should post it up on github. I thought about automatic vs explicit dependencies a good bit when designing S, and I have to say, I'm more and more confident that automatic dependencies aren't "too magic," they're usually the right thing. This is a bit intuition and a bit experience after writing a few 100k lines of S code. Let me see if I can summarize. From least to most important, automatic dependencies are:
var a = S.data(true), // like flyd.stream(true)
b = S(() => a() ? 1 : c()),
c = S(() => a() ? b() : 2);
That's my best effort to summarize my experience to date. Happy to hear your thoughts :). |
@adamhaile Thank you for chiming in. It's interesting to hear your take on automatic dependencies. Early on I actually implemented automatic dependencies in Flyd. But, I later removed it because I didn't like it.
That depends on the use case. With automatic dependencies, you are writing functions that are hard-wired to the specific signals they depend upon. This means that they cannot be reused. For instance, let's say I have to sum both abSum = lift(add, a, b);
cdSum = lift(add, c, d); If I had written
One could flatten it. There are several ways to flatten a stream. How do automatic dependencies provide a benefit here?
I am not familiar with the "constructivity" issue in synchronous reactive research. That is what I would call a circular dependency. Some FRP libraries solve that using a form of lazy evaluation. Which also seems to be what you're doing here. The body to
I don't see how that would end up as a subtle bug? I think it will end up as a very non-subtle bug. If it was a big issue one could implement
Explicit dependencies are declarative about both values and dependencies. While automatic dependencies are implicit about dependencies. Thus it don't see how explicit dependencies makes it harder to reason about values. They should be equal in that regard. I think the keys to a good mental model in a reactive library is to make a distinction between values that change over time and events that happen over time. What in classic FRP is called behavior and event. I make a case for the distinction here and I've implemented such a library called Hareactive. It seems to me like S.js only has a representation for values that change over time and not one for events? |
I have some reading to do, and feel free to show off other libraries you think are interesting or relevant! But to answer your question of "why", I understanding what you're saying about Check out this gnarly component I built in a project of mine. Its responsible for the "pie" on the left side in this demo. Its not the cleanest code -- I just hacked it all together as quickly as possible. But I think it really highlights the benefit of everything. For example, in the |
Turbine looks really cool! Its basically Cycle.js from what I can tell. A couple concerns of mine:
I'll have to dig into it some more, but those are my initial concerns. |
@ccorcos Thank you for sharing your initial concerns. I really appreciate it.
I get why you say that. At first sight, it has some resemblance to Cycle. That is not a bad thing, Cycle is great in many ways. But there are actually many differences. The more you look at it the less it looks like Cycle I'd say 😉 Here are some of the differences:
Overall I'd say that Turbine has a slightly higher learning curve because we use some more advanced ideas from functional programming (monads, for instance, are crucial to our approach). But I hope the final experience is a framework that's more convenient and powerful to use.
You are definitely right that it is non-trivial to implement. We have to pull some tricks to establish the circular dependency. But conceptually it's quite simple to understand. Creating types for it is actually easy. function modelView<M, V>(
model: (v: V) => Now<M>, view: (m: M) => Component<V>
): Component<M>; As you can see,
I don't understand what you mean here. What sort of callback functions do you mean? I don't think they exist in Turbine. We have a special function I'm not sure if that explanation makes sense or answers your question 😢. If you could expand a bit more on the question I'd like to give a better answer and/or concrete code that implements what you ask for 😄 |
Spent some more time looking through Turbine this morning... That's some pretty intense stuff! It's very well thought-out, but building a mental model for how it all works is pretty challenging. I think if you included explicit type annotations in the tutorial, it would be a lot easier to pick up on. I think it might also help me understand how everything works if you had an example that showed me how to get all the way down to the actual DOM node where I could do things like call I see what you mean about the differences though. It actually is a bit different. No selectors is 👍 and the code is really clean. I'm still trying to figure out where the challenges will be... Here are some of the things I'm still thinking about:
class Counter extends PureComponent {
state = { count: 0 }
inc = () => {
this.setState({ count: this.state.count + this.props.delta })
}
dec = () => {
this.setState({ count: this.state.count - this.props.delta })
}
render() {
return (
<div>
<button onClick={this.dec}>dec</button>
<span>{this.state.count}</span>
<button onClick={this.inc}>inc</button>
</div>
)
}
}
<Counter delta={10}/> To stretch this abstraction even further, I might want to have two counters: the first counter has a delta of 1, and the second counter has a delta of the value of the first counter. Here's how I would do it using import { Value } from "reactive-magic"
import Component from "reactive-magic/component"
class Counter extends Component {
count = this.props.count || new Value(0)
inc = () => {
this.count.update(count => count + this.props.delta)
}
dec = () => {
this.count.update(count => count - this.props.delta)
}
render() {
return (
<div>
<button onClick={this.dec}>dec</button>
<span>{this.count.get()}</span>
<button onClick={this.inc}>inc</button>
</div>
)
}
}
class App extends Component {
delta = new Value(1)
render() {
return (
<div>
<Counter count={this.delta} delta={1}/>
<Counter delta={this.delta.get()}/>
</div>
)
}
} What intrigues me so much about this example is how clean the mental model is. It feels very easy to make sense of to me. |
I saw this interesting discussion and taking this "invitation" perhaps too literally :), I'd like to share the un-project, that was also discussed in the context of the Turbine here: funkia/turbine#34 Its main goal is provide a minimal "glue" for other libraries and frameworks, I have tried to extract and abstract the essence of this pattern, In the basic examples both model and view are basic pure functions, importing abstract element or component creators like In the Turbine, the view function takes the However, the abstract pattern of the view taking state and returning component remains the same, where the dispatcher callback is replaced by the component output. And the accompanying model is closing the view's "microCycle" by taking the output and returning the state, wrapped in some monad to make it pure. The model also holds internally some initial state (the So on the abstract level, the model takes some external state and the view's output and returns some updated state (like adding new actions to it), passed back to the parent, that includes the internal state, to be consumed by the view. And again, we arrive to the same reducer picture: (state, action) -> state So it looks like there is always the same abstraction behind that I would like to extract Which is what the un-project is after and I will love to hear any feedback or critics. 😄 |
@paldepind slow reply as I was at a conference. Good questions, and let me see if I can elaborate. I just want to mention, by the way, that I hope I don't seem like I'm coming into your Issues and insulting your work. This is in the spirit of sharing experiences between implementers. I particularly think hareactive is interesting and want to follow its development. Also, to provide a bit of context on the discussion, automatic dependencies aren't a goal in S, they're a means to an end. The central abstraction in S is the unified global timeline of atomic instants. Synchronous programming literature calls this "logical time" or "the synchronous hypothesis." Automatic dependencies are just one part of achieving that, along with things like guaranteed currency, immutable present state, and so on. With flyd, the core abstraction, if I have it right, really is the dependency graph. I don't know whether I'd want automatic dependencies in that scenario. It takes the whole set of behaviors to make "logical time" a strong abstraction in S. So anyway, to your points:
In regards to conciseness, I was thinking of the fact that flyd's form requires stream names to be repeated thrice, verses once in S (aka DRY). Expanding
I see this being particularly an issue when we have a higher number of streams than 2. The real-life case that immediately jumps to mind is the className attached to a component's main element, which often includes numerous decorator classes to indicate different states of the model. Determining which are active can touch on dozens of streams. Repeating each of those three times, and making sure we do so with parallel ordering where needed, strikes me as burdensome. The verbosity grows with complexity. Say we want to sum a().b().c() to d().e().f():
You say that lift allows you to abstract over the streams and get real-word reuse. Given that we're almost always constructing S computations inside a function, that function has already abstracted over the streams via its parameters. So it'd have to be a case where we wanted to use the same named function with different streams in the same closure. I can't think of a case where that has been a feature I wanted in a real world program. Generally, the definition of the function is closely tied to its context, so function expressions are used rather than named functions. It's possible that we have different styles here, so let me know if you had an example in mind.
On the contrary, b() and c() are both well-founded. Their values are determinate in all program states, either 1 or 2 depending on a().
S is an eager library and calls the passed function immediately. b()'s initial evaluation does not call c(), so it doesn't throw, and c() is defined before a() can change and cause it to be called. One further point I'll add is that S's automatic dependencies are strictly more powerful than explicit ones in that lift is trivial to implement in S (leveraging S.sample()), but automatic dependencies can't be implemented in flyd. I didn't mention this last time because I thought @ccorcos 's Reactive Magic might be a proof-by-counterexample, but after looking at the code, I see he's only generating dependencies from the first evaluation.
Dependencies yes, but values are only known if we take into account the full history of the system, due to the fact that undeclared streams end up preserving prior states (whatever their value was the last time one of the declared dependencies changed). An S computation is guaranteed to be a function on current state. A flyd dependent stream is only guaranteed to be a function on total history, which isn't much of a guarantee. To give a concrete example:
If we ask "what's the value of abcSum," it's not a() + b() + c(). It's "c() plus the value of b() the last time c() changed, plus the value of a() the last time b() changed before the last time c() changed." With just two streams and a toy function, the missing dependencies are obvious, but would they be with more streams and a much larger function body? If the recommendation is "don't do that, be sure to list dependencies for all referenced streams," then isn't that identical behavior to automatic dependencies but with a lot more hassle to the user? You can, by the way, build the same behavior in S, but you have to be explicit that it's what you want, not merely forget to list a dependency for a referenced stream:
S.on() and S.sample() limit the events on which the given functions run.
There are two axes available for abstraction: what kind of values we have and what kind of time. FRP abstracts on values, with a taxonomy of events and behaviors, while SRP abstracts primarily on time. I can point you towards papers if you're curious. This paper on Lucid Synchrone is a nice introduction.
Currently, you're correct ... ish. This is an intentional experiment with S. I actually built a couple apps with and without an event type and found the one without much easier to reason about. I also found training (I've got a couple other guys writing S code) much simpler without. In fact, I couldn't come up with a scenario for which an event type led to clearer code, but perhaps you can :). Events are always the point that Reactive Banana tutorials go "ok, I know this is getting confusing ...." I can go into my thinking here, but have obviously abused your time enough at present. Cheers |
Again, thank you for the feedback. I really appreciate it 👍 Since there are now several things going on in this thread I've answered your comment in a new thread here funkia/turbine#51. I found your comment to be really interesting. I'll have to take a look at the paper you linked and then I'll give you a proper reply 😄 |
I was off-thread for the whole time and not sure I can catch up with all you'd discussed here. But I got in mind this thing: pmros/cyclow |
You don't seem like that at all 😄 Sorry if I came across as defensive. I definitely enjoy the spirit of sharing experiences. Having one's work insulted is a great opportunity to learn something and create even better work in the future. I find S very interesting. I think you make a very good case for automatic dependency management. I am actually seriously considering if I should implement it in Hareactive for the cases where it's useful. If you can say more about the benefits I'd love to hear it 👂 I think maybe we are approaching reactive programming from two different sides. Clearly, you know a lot about synchronous programming. I've never looked much at synchronous programming. But, to be a bit blunt, that's because FRP always seemed superior to me.
I can see that it is well-founded 😉 The body of the definition of
In FRP
I can't dispute that the S code is a lot more concise in that case. If reaching into a heavily nested structure like that happened often to me I'd probably create a helper function to do it.
The core abstraction in Flyd is the stream. The dependency graph is merely an implementation detail. But, Flyd does not reflect my current opinions on the ideal FRP library. Hareactive does. In particular, Hareactive follows the semantics of classic FRP where the primary abstractions are behaviors and event (what FRP traditionally call "event" is called "stream" in Hareactive).
To me, FRP is definitely an abstraction over time. A behavior is semantically a function over time, i.e. a value that changes over time. I mentioned above that I think FRP is more attractive than SRP. Here are my two primary reasons. The paper you linked describes their abstraction over time as "infinite sequences". That makes it ill-suited to represent continuous phenomenon as sequences are discrete. The fundamental problem here is that time is treated as if it was discrete. But, that is not the way humans experience time. It's not the way physicists model time either—because discrete time is a poor model of the real world. It's also clear from the paper that not only is their abstraction over time discrete, the "discreteness" is also exposed in the API. For instance, their FRP, however, defines behavior as functions over time. And, importantly, time is represented as the reals. This means that a behavior in FRP has infinitely dense resolution. This makes it strictly more powerful than the abstraction described in the paper. Continuous behavior can represent things that change infinitely often. Discrete sequences are countable but the reals are uncountable. The second reason is that SRP, doesn't make a distinction between behavior and event. Conceptually these are two different things. I have written more about why that distinction is useful in a blog post here. To supplement the blog post, let me offer a real-world example: let's say we want to implement a game where the user controls a circle and the enemy in the game is also a circle. The player starts with 5 lives and each time he collides with the enemy circle a life should be subtracted. Each circle has radius 10 so the circle collides when the distance between them is less than 20. // Continous behavior of player position as the center of the circle
const playerPosition = ...
// Same for the enemy
const enemyPosition = ...
// The distance between the player and the enemy
const distance = lift(({x, y}, {x2, y2}) => Math.sqrt(...), playerPosition, enemyPosition);
// Behavior that is true whenever the circles overlap
const areCollided = distance.map((d) => d <= 20);
// event that triggers on collision (this is similair to `edge` in SRP)
const collisionEvent = areCollided.changes().filter((value) => value); // keep when changes to true
const nrOfLives = scan((lives, _) => lives - 1, 5, collissionEvent); Note here how I start out with a bunch of behaviors, but when I want to represent collisions I shift to events. That is because conceptually collisions are event that happens over time. Here making a distinction between the two is highly beneficial. The types tells me what phenomenon I'm modelling and they restrict me to the operations that make sense on them.
I find it odd that you think event is the hard one to understand. Most reactive libraries in JavaScript only offers an abstraction that is much closer to FRP's event than it is to behavior. IMO neither are hard to explain. I make an attempt in the previously linked blog post. Also, in the example on the S readme you seem to get around the lack of events by pushing into |
I've looked a bit more at S. And it actually seems like the signals in S are more like FRP's event than like FRP's behavior. In particular, the "reducing computation" feature exposes how often a signal is pushed to. Such an operation cannot be explained with the semantics of behavior (a function over time) but can only be explained with the semantics for event. The |
Closing as this it not an issue. |
Hey there, just thought you might be interested in a little library I build on top of Flyd called Reactive Magic.
Perhaps the most interesting thing is how you magically combine streams:
I built some abstractions and created an interesting React API. Its pretty neat actually. I don't like how magical it is in some ways, but it actually makes it much faster to build things.
The text was updated successfully, but these errors were encountered: