Starbeam is a library for building reactive data systems that integrate natively with UI frameworks such as React, Vue, Svelte or Ember. In this talk, Yehuda will announce Starbeam. He will cover the motivation for the library, and then get into the details of how Starbeam reactivity works, and most importantly, how you can use it to build reactive libraries today that will work natively in any UI framework. If you're really adventurous, he will also talk about how you could use Starbeam in an existing app using your framework of choice and talk about the benefits of using Starbeam as the state management system in your application.
Announcing Starbeam: Universal Reactivity
From:

JSNation 2022
Transcription
Hi, everybody. Today I'm here to talk to you about Starbeam. Starbeam is a library for building reactive user interfaces based on the guiding principle that if you write your reactive code just like any other code, you can build a radically simpler, more reliable user interfaces with the JavaScript skills you already have. Now, people have said a lot of similar things like that before, so I don't blame you for being skeptical and jaded. Before I get started, I want to be really clear about something. Starbeam is not the next hot new JavaScript framework. The demo I'm doing today uses Starbeam inside a React app, and we have similar examples in Svelte, Vue, and Ember. The people who work with me on Starbeam are pretty tired of the way that new front-end ideas are always packaged up with a whole new framework you need to migrate to. We think we can do better, and I hope that by the time I'm done here, I'll have piqued your interest. Let's start by taking a look at what the app that we're going to be building looks like. Here's the finished app. We have a form, we can create some new users. I can write Lea Silber Portland. I can append it. As you can see, once I have appended it, there's some information on the bottom about what happened. I can filter, I can write Fort Portland here. When I do that, you see that this changes to say two filtered out of five. I can delete items on filtered. Everything works as you would expect. I can delete all the items. I can add people back in, like that. That's about it. That's basically what this app does. Well, the very first thing I want to do is change to using the non-finished version of the demo. Let's start there. Great. As you can see, there's some features here already, but there's no filters, there's nothing that made it very interesting. More importantly, nothing works here. Before we get started, let's take a look at the data that backs our component. Now, it's a little bit of an involved JavaScript thing here, but the reason it's involved is less that StarBeam cares that you do anything interesting here, and more that I want to show that even if you use pretty advanced fancy JavaScript features, everything still continues to work. Let's take a look at the first piece of this, which is the table. A table has an ID which starts at zero, and that's what we can allocate IDs as we create new rows. We have a list of rows, which is a map from a string to a row. Now, what's a row? Well, here we have an example of a person. A person has a name, which is a string, a location, which is a string, and all a row is, is that thing plus an ID added to it. We're going to map from the ID to a row of a person in this case, and that means it's going to be the person plus an ID. We're going to take a bunch of columns so that we have a way of reflectively creating headers. We're going to have a getter for rows. What does the getter for rows do? It gets the values out of our map and splats it onto an array, it gives us an array back. How do we append a row? Well, we make a new ID as a string, and then we make a new object that contains the ID and the columns that we specified. Then we set the string ID onto the map and give it the row as value, return the row, pretty vanilla stuff. How do we delete an ID? Well, we delete it from the row. How do we clear rows? We clear the map. Then there's one additional feature here, which we're going to use to implement filtering, which is the query feature. What is query? A query is another very simple object. A query has a table that backs it, and then it has a bunch of filters and an optional sort. What's a filter? It's a thing that takes a row and gives back a Boolean. What's a sort? It's a thing that takes two rows and gives you back a number, which is what the compare functions do. Now, what happens if you ask for a list of rows from a query? Well, the first thing that happens is that we get the rows from the table and as a reminder, that does this splatting thing. Then we loop through all the rows and we make sure that all the filters match the row. Then if we have a sort, we sort the filtered things by the sort, otherwise we just return the array. Basically, it's a little object, wraps a table, has some filters, and gives us back new rows. That's the table, that's the query, and then we have a little people class. What is a people? Well, it has a table of person, it has a query of person. Then what happens if you get the table, it gives you back the table. If you get the rows for a people, it gives you back the queries rows. Then there's two convenience methods for adding sorting and filtering. Let's look at filter first. You can filter based on text. What it does is it takes the existing query, it adds a filter to it that checks if the rows name includes text or the rows location includes text. Then it returns a new people object with the table on the query. Then what does the sort method do? It takes a column, so that would be like name or location. Then it takes a locale. The reason it takes a locale is because we're going to want to use the Intel API to sort things so that we get the right behavior for different languages. This looks like a lot of things are going on here, but really all that's going on is we're making a new collator for the locale that we passed in, and then we're giving query a sort, which is going to get the values out of the column. Let's say we're sorting by name, so we're going to get the name out. Then we're going to use the collator from intel.collator to compare and gives back a new people, so that when we go to get the rows, does the right thing. Now, here's the most important thing. You didn't have to follow necessarily all of that, but the thing to note is that there's no reactive concepts in here. This is just an object, everything like locale is just a string. There's nothing about hooks, there's nothing about needing to run something multiple times, there's no special objects that you need, there's no piping. There is one place that we've seen so far where we have a reactive object, which is right here, this reactive map. But other than that, all we've seen so far is just functions that work with that ultimately that underlying map and produce new values. That's basically how the data works under the hood. Now what we're going to do is we're going to go look at our app. We replaced the app already with the unfinished data table and now let's take a look at the component. The first thing that you're going to notice here is that we have this useStarBeam hook. UseStarBeam is basically wraps your entire component and its job is just to get you into StarBeam mode so you can access and work with all the reactive data. Now inside of a useStarBeam component, there's two sections. There's the top section, everything up until the point where you return this JSX chunk. The top part of a useStarBeam function runs exactly one time, and it's a good place to set up stuff like this table that you only want to set up one time or functions that you don't want to have changing under you. The bottom part, this callback, this chunk of JSX, that's the thing that runs over and over, so that React has JSX to reconcile. But the critical thing here is that StarBeam makes sure that it doesn't rerun if none of the dependencies, the reactive dependencies that are used inside of the callback run. We'll see what that means in a minute. You can think of it as a React.Memo on steroids. Let's take a look at the actual implementation here. We have a JSX, we have this form here. If you run the form, you can see that the form doesn't really work. Because if we look at what the append function does, it doesn't do anything. We'll take a look at that form in a second. Then we loop over all the columns on the table, and just remember the columns are just a field that's on the table, and we turn them into THs. Then we have one last TH here that gives you ability to clear all the rows. That's what happens if I clear all the rows. We'll talk about that in a second. Then I loop through all the rows that I have, and for each one, I make a TR, I give it the key of the ID that we created, and then I give it TDs for the person, the name and the location. Then I have one final action here which allows me to delete the person.id. Now, I just want to reiterate, when you look at clear, that's just doing rows.clear. When I do table.deletePersonId, that's just doing rows.deleteId. Even though these are callback functions that are happening inside of here, they're happening inside of JSX, you don't have to do anything fancy here, they're working with StarBeam data. Then finally, we have this summary section over here. The summary section has a data items attribute of the length, and that's basically so that when there is zero of them, there's some CSS that makes it look different. Then we have a TD which prints out the total. What is the total? Right now, it's very simple, just says items colon table.rows.length. Cool. Now, what I want to quickly look at here is, we already have some interesting data here. If I reload and I start deleting things, you can see that there's already items two here, you can see that the array got filtered and stuff like that. How did that happen? What exactly happened here? Notice that we don't have any use state, we don't have any dependency arrays, we're not using React concepts really at all. How does that work? The way it works is that if we go back and look at our table, like I said, there's this little piece of reactive state here. When you get the rows of the table, it gets this.rows.values, and StarBeam knows that when you do this.rows.values, that means that you are reading the iteration of the whole map. That means that whenever anything changes the iteration of the whole map that will invalidate the read of this.rows.values. But the thing is, we don't actually use the table rows directly most of the time. What we mostly do is use it through other abstractions, and we'll see that's going to get more and more elaborate over time. The point is that it doesn't matter if we specifically use people.rows here or if we use it through something else. The fact that we access the data that is inside of people.rows somehow. People.rows just as a reminder is not actually directly, it's not the table, it goes to the query, and the query as a reminder goes to the filter. There's actually a lot of steps in between what looks like, oh, it's just the rows, and that piece of state. But you don't have to say anything about that, you are able to just write normal JavaScript code here. That's great and all, and what that basically means is that when we say, table.deletePerson.id, StarBeam knows that this people.rows.length invalidated and therefore knows to re-render the template, not the template, the JSX. What's the next thing we want to do? Well, we have a non-working form here in this append, so let's go and implement it. The thing we have right now is we just have edaprevent default. Let's take a quick look at the form. We have a form here, it has an onSubmit, and then we have input type equals text, name equals name, so we're using the HTML way of giving it a name. We're saying it's required. There's some CSS stuff here for errors. The second input field has a name of location, we have a button type equals submit. That just means that when we hit enter, we hit tab and space, we click whatever it's going to call the submit. What do we want to do with that? Well, the first thing we need to do is get the form out of the event. The second thing we're going to do is we're going to make a new form data out of the form. Now, if you don't know what a form data is, it's pretty interesting. What it basically does is it takes the form, it gets all the names of fields that are valid. In other words, non-disabled fields and stuff like that, and it turns it into an object that has those keys and those values. What's really cool about form data is that it implements iterable of entries. You can just say object.fromEntries of a form data and you get back something that looks exactly like what you would expect. It's a little bit of incantations here, but we take a form, we make a form data, we do object.fromEntries, and we get back this. We get back an object that has a name and location, which is precisely what we want. Our table has an append on it, the append takes columns, which is the name and location. When we're done with that, we're going to reset the form, so it empties out what we already did. At this point, we would expect it to work, I think. Let's check. I'm going to add Leia Silver and I'm going to add Portland, and it worked. Great. Why did that work? What exactly happened here? What basically happened is that when we clicked on table.append, that set a row on the map here. Because other parts of that same component read from ultimately, this.rows.values over here, which is an iteration, the fact that we added something to the map means that it invalidated anybody who cared about the iteration. In other words, because at some point, I computed a list of all the keys and the values, if the keys and values change, then anybody who read the list of keys needs to recompute. The fact that I called append, it looks like I'm just mutating an object. But behind the scenes, StarBeam keeps track of every read, every write, and links them up together. Cool. Now that we did that, the next thing we probably want to do is implement the filter. In order to implement the filter, we're going to need a cell. A cell in StarBeam is just this very simple object that is a space for storing one thing. We have a cell here and it's a space for storing a string, because the filter is going to have a string in it. We're going to want to make another field here. That's just going to be our filter. What does that field look like? Well, it's going to have a default value of the filter.current. Filter.current is reading from that cell. That's a reactive thing, it's reading from the cell. What happens when you type something in? No problem, we set the filter. Filter.set is how you modify a cell with the value of the current target. Now, if I type something, I was able to type in the filter, but we didn't do anything yet, so we still need to do something here. In order to make it useful, we don't want to just make the rows just a bunch of rows, we want to make a query. What's going to be the query? The query is going to be the people table that we have, and we're going to use that filter function that we wrote before to filter based on filter.current. Now, note, just accessing filter.current in here is enough to know that we're accessing the underlying filter. We have a function here called query, it's a regular function, nothing special here. We're making a new query which filters by the thing we typed here, and we're also going to sort by the locale for good measure. Now that we did that, we're just going to update the rows here to return the queries rows instead of the tables rows. Now, if I type something, Portland, Yehuda, if I type Chirag, if I type NYC, all that worked. Why did that work? Well, part of why it worked is that the filter does this somewhat complicated thing. It goes and for each row, it checks if the name or location includes the text. When we said query.rows, that's the thing that actually went ahead and ran the filter. Again, this is just pretty normal JavaScript code, and all we did was make a new function here called query, and all we did was make our existing function rows use the query. But the fact that this code down here, the code that loops over says rows, the fact that that accessed the query, what does that mean? Well, it means that it has a dependency on two things. It has a dependency on this filter thing, and it also has a dependency on this rows thing, which has a dependency ultimately on the table rows, which is this iteration. Basically, just the normal way of accessing, the normal way of writing code here means that our computation of rows here caused StarBeam to know that this whole component depends both on the filter and it also depends on the rows. One thing that I think is worth noting here is that there's surprisingly a few pieces of root state here for a pretty complicated setup. We're going to add one more feature here. What we're going to do is we're going to make it so that if we filtered something we want this to not say items for, we want it to say items some amount of filtered over for. How are we going to do that? Well, we're just going to replace this total function here. The first thing we're going to do is we're going to get our rows and that's going to be the amount of rows that were in the query. The second thing we're going to do is we're going to get the total number of rows in the table. Now, if the two things are the same, the filter count and total count, we return what we had before. Otherwise, we say items filtered over total count total. If we just reload the page, obviously nothing happened here. But if we start filtering, we say charog, we say NYC, we can see it worked. Once again, this total function has a dependency on rows, it has a dependency on table.rows, it has a dependency. What does rows have a dependency on? It has a dependency on the query, which has a dependency on the filter. All this is just a pretty natural way of writing normal code without having to write anything special, without having to say anything special about how things are connected to each other. It all works out. Now, I keep saying things are dependencies of other things, but you have to imagine that. But let me show you a little debugging tool that we have that lets you see it more explicitly. To turn it on, we're just going to grab the logger from StarBeam. We're going to grab the logger from StarBeam debug. We're going to say logger.level equals loglevel.debug. Now, once we do that, if we open up the DevTools, we see that we're starting to get some additional information. It's showing us that this useStarBeam block, which is located over here. Let's pull that down. You can see exactly where it's located. It tells you that that has a dependency on this cell as a dependency on this map. I'll make it a bit smaller. Now, the next thing that we can do with this debugging tool is if we invalidate something. Let's say we go back to this filter here and we say C. What we're going to see is that it tells us that useStarBeam invalidated and tells us exactly what invalidated, which is this cell here, and it gives us a link to exactly this location. Now, this is a little bit noisy. Built into this setup is a way of saying for each new piece of root state, you can give it a name. Here we can say cell, we can give it a name of filter. We can go to our table and we can say, I already did it, I gave map a name table. I think those are the only pieces of state. Now, if we look at it, we'll see that it gives us the names instead of the very long words. If you wanted to turn it on and you want to see the stack traces anyway, the longer links, so you can click on them, you can just always turn it on to trace mode, and that will give you the links no matter what. You open it up and you always have these links here. Great. Let's turn that off. Now, we have one last feature that I want to implement, which is so far everything is self-contained inside of StarBeam. But like we said, this is inside of a React app. We really would like to be able to have this component take some arguments from React. In this case, what I want to do instead of just using the system locale as the sort, I want to get the locale from the React app. In order to facilitate that, I created a locale switcher here. We're just going to implement it using a pretty normal React pattern. We're going to use state. We're going to grab some stuff here, we're going to format the locale. But it's a select box, it sets the state. Now that we did that, if we turn it on over here, we can see that we have the select box. What we would like to be able to do is just pass the locale directly into our component. Of course, there's no reason why we wouldn't be able to do that. This is just a regular React component. We can say props locale string here. But there's a bit of a problem here, which is that the way React works is it's just going to keep calling this function over and over and over again with new props, but StarBeam doesn't really have any idea what that's about. We really need a way of turning this locale into something that StarBeam understands. For this situation, there's a hook that we provide called use prop. We can say const locale equals use prop props.locale. We're going to pull in use prop from StarBeam React. Now all we got to do here is we just have to change system locale to locale.current. That just shim the React world into the StarBeam world. Now here's why I set it up this way. This umlotted word name is differently sorted in Swedish and other languages. Let's switch to Swedish real quick. As you can see, it's now sorted at the bottom. Let's bring back up our DevTools. Now what we can see is that we actually have three pieces of state here. We have the filter cell, we have the prop, and we have the map that we've been talking about the whole time. Just like anything else, we can give this a name. Let's just call it props.locale for convenience in our debugging. Now we can see we have those three things. Now if I go and select Swedish, you can see that that is exactly the thing that invalidated it. If I was to instead type in NYC or something like that, what we can see is that now we know the filter is the thing that invalidated it. Now this is still a pretty early debugging tool. This is just a logging just for convenience. We would expect some more elaborate debugging tools eventually built on this infrastructure. But the basic idea here is that from StarBeam's perspective, this function over here has dependencies of just three things. Now one of the things the map is a little bit of an elaborate thing so that we can do more granular tracking. But in general, there's only three pieces of root state. It doesn't matter that we have this total function. It doesn't matter that we have this rows function. It doesn't matter that when we go in and look at this rows thing, it's doing this complicated going through multiple steps, it's doing this complicated filters thing. None of that matters. At the end of the day, the only thing StarBeam actually cares about is that it has a dependency on filter. It has a dependency on the props, it has a dependency on the map. Whenever we go and change something, let's go create a new person. Let's make a new person. We'll make Leia again and we'll say PDX. What you can see is that the thing that invalidated is the map. At the end of the day, even though there's a lot of complexity here in your code, that complexity is all in very standard normal JavaScript stuff. The reactivity is really just saying that this callback here, this thing that generates JSX has a dependency on three things. It has a dependency on a map, the iteration of the map, it has a dependency on a filter, and it has a dependency on the locale prop. Now, all StarBeam has to do is keep track of those three things. Whenever they change, it invalidates and notifies React and invalidates. The nice thing about this is that if you have a lot of components in your app that use StarBeam, each individual one doesn't invalidate any of the other ones. Like I said, it's like a souped up React.Memo. What happens is, without you having to say anything, simply by accessing values using normal functions, using normal getters, using normal methods, using normal access patterns, you're telling StarBeam exactly what the dependencies are. StarBeam is able to keep track of exactly when it needs to invalidate. You can shim into the React world pretty easily too if you need to, and that works totally fine. Now, one thing that's pretty cool, I think that last thing I want to say here is, if you go back and look at our table, there's no actual imports from StarBeam React or from React. That's because this code, this table code, this query code, this people code, this is all pure StarBeam stuff. That means that this exact same code will work in Svelte, it will work in Vue, it will work in Ember. That's pretty cool. It means you can start writing libraries that are like this toy example, but bigger, something like a GraphQL library, and you can write it mostly in StarBeam, in StarBeam concepts, and then expose it to React, to Svelte, to Vue using these adapters. You don't have to build a universal code in StarBeam, you can just build stuff inside of the React app, but you also could build these universal libraries, and I'm pretty excited to see what's going to happen once the ecosystem has the ability to start writing reactive code in a way that's decoupled from individual frameworks. I think that's going to be really awesome. If you're excited by this, definitely come check it out, come to our Discord, you can come to our GitHub, you can start, you can like it, you can submit issues, you can try to integrate it, you can try to do some work, you can try to help with debugging tools. There's so much stuff going on. I'm pretty excited about how far we already got, but there's still a ton left to do. If you're the kind of person who likes getting in on the ground floor and making a run for something, great. Join us, we're excited to have you. Thanks so much and enjoy the rest of your conference. Thank you.