Video details

The Tricky Brilliance of the Uncontrolled input element | Justin C. Moore

React
09.01.2020
English

Justin C. Moore

React Chicago August 2020 Virtual Meetup
The Tricky Brilliance of the Uncontrolled input element Justin C. Moore
If you build it, will they come? When tasked with creating a UI Design System in React, encouraging "best-practices" and motivating adoption in colleague and peer communities is no easy task. For inspiration, we might turn to the React library itself, which has managed to do both. Here, we'll look at one of the subtle details that makes React so easy to get started with, and examine a pattern intended to make it simple to scale.
Justin C. Moore is a Senior Software Developer on the UI Framework team of ad tech enterprise MediaMath, Inc. He thinks in JavaScript, and has loved it long enough to know its (not so) hidden flaws. He dabbles in language design, static analysis, distributed systems, databases and persistent data structures. If you've caught him not coding, he was probably performing a dead lift, singing karaoke, catching up on Acquisitions Inc. or other "nerdy" media, and pondering nature of reality.

Transcript

First of all, hello, everyone, and thanks for taking the time to attend this evening. I hope that everybody is managing 20 20 reasonably as well as can be expected. I'd like to share with you a component API approach that I arrived at, which takes a bunch of cues from native input. And so here we have our humble input. It simply allows a user to type in a line of text. And I like to think of this as the component that launched a thousand Web apps. Countless developers, self included, have been lured in by and to react by the kind of elegant appeal of of the uncontrolled input. And using the native input, one can get visuals on the screen almost instantly, and it's almost effortless to make it interactive. This is a particularly because it's actually optional to specify explicit controls which people will most commonly do by passing in like a value and an unchanged from. So at the same time, the component lets you lets the developer do everything that they need to do when you're when you're dealing with the native input, you're never left wanting for more capabilities from the input than its by project. So all. And although it has this default behavior, you're still able to completely control the behavior to the needs of your abilities. Regrettably, when it comes to developing custom componentry, usually stifled by having Apison custom components that don't lend themselves well to complete control or their very cumbersome to use, because they require you to implement a bunch of logic in order to get it working properly. At least that has been my experience as part and a little bit about me. Hi, I'm Justin. I'm into weightlifting, karaoke. I like static analysis and data structures. I would describe myself as being curious so anyone who has an open spot in the campaign feel free to slide into my DBMS, my Twitter verse about that. In any case, you know, I was a former mobile game developer and currently I'm a front end developer at ad tech company called MediaMath, and at MediaMath I work on a team that is implementing a design system. And IT design system is really just a collection of reasonable composable and standard but standardized UI modules. And they're not necessarily implemented in reacts. But the goal of a design system is really to create a consistent user experience across across an organization or even a community like the material design system from Google. They hope that all Android developers will and and some of the developers as well, sort of use this material design system so that there's a consistent look and feel. And we want to do much the same thing in the application that we the applications that we develop MediaMath. And in addition to that, our design system aims to promote an advantageous set of best practices that will increase equity of of our code and limit the tech that as much as possible. And so in order to achieve that, in order to achieve those goals, that the components really need to be applicable to a wide a broad range of use cases. And that will typically mean exposing complete control of all the components. And unfortunately, that also means some state management and. Maybe my experience is different from both, but the task of trying to keep state management scalable and ergonomic has long been a thorn in my side. Working with vets react as as a program gets larger, and often I find myself being confronted with more questions than answers. Where should I keep states so I keep it locally? Should I keep it globally? Should I use one store chains, multiple stores? How am I able to update the state that I need to update when I need to update it? And so in the search for general solution to some of these some of these problems and keeping in mind some of the characteristics of native input, which is so nice and pleasant to work with. I've got an approach that I'd like to share that aims to do the following things, one, it will expose complete control of a component so that those components are applicable in a wide variety of use cases. And at the same time, it provides the same kind of easy, easy default implementation of its state logic so that it's also really trivial to get started with to get something on the page and. So. As promised, this is the approach that I've got and to see how it works, to show you how it works. We'll do a short exercise where we build out a little three component designed system right now. And to spoil the ending, we'll be developing a standard component structure that enables a component callback property signature to normalize how state is managed for all components. So if there's one thing to take away from this talk. It would be that there are two things to take away from this talk, and the first of those things is the idea that logic, the logic for updating state. That is functionally local to a component can be decoupled from the mechanism that stores the state itself. That is the logic, the logic that knows how the outdated component doesn't need to live right next to the place where the state itself is stored. And then given that a component has the opportunity to express to one of its ancestors how it expects its own problems to change by default in response to that component's updates events right when those events are triggered. So the rest of the slides are going to be a lot of code, there's going to be a lot of code because we're implementing this design system and we'll be. Well, we'll talk about these things using a couple of terms in mind, so I'm going to use this term actions as a term to describe an object that describes an event and it will typically have a type label as a string. And then adapted functions, I I mean, or just functions that will take one set of parameters and call a function with another set of arguments. So to bridge the gap between dissimilar APIs. Then we have reducers which are functions that can. Using using a state and an action, using some value and an action, we'll give you another next value based on that action. And this should be familiar to anyone who is use redux or even reacts native use producer HUTZ. And then lastly, there's updater functions, which are just like reducers only, they don't have an action parameter. And so you might encounter these if you use use they statehood. Let's see, so any questions so far? Right, you moving right along. So the first component that we're going to implement in our design system is a very simple input, like the one that we saw before. It just takes some text. So. First of all, start by declaring a functional component. And so we'll call it a value in. And this is going to be to get started just under some native input as our day, and this is enough to to get the ball rolling, but this component right now is totally uncontrolled and we want it to be controlled. So we're going to have to manage some props. And in particular, we're going to start with non non combat troops, so our value input is going to require a value prop that we can pass straight through to our native input. And this means that the parent of this component can totally control what the text in the input is going to be. But in order to respond to user events happening in the in the input, we're going to need to do a little bit more. So we're going to have to have a call that we'll call it unchanged. It will be a function that gets an event and causes something to happen in response to that event. And in the simplest way possible, we'll just pass that prop directly into our native. So this is all we need to have a totally controlled value input, which is one of our one of our initial. Next. Let's try to use that component in an application so first will render out a value input component, but we will need to pass some props to it and to support that, we might use a local state hook to set up state for the value of the input itself. We'll pass the value directly into the component, but we also need to handle the change events that are coming from the input so we'll specify an adaptive function to bridge the gap between the callbacks signature of the value input and the state management data by passing in the event target value directly into the new state. So that's pretty easy and we feel good about the simplicity of implementing this value component, but if we want to reuse this component somewhere else, we have to duplicate an unfortunate amount of code. And some of this code, all the code is very simple now, but in a more complicated scenario, this can get out of hand. And the the really troubling code that we want to try to eliminate is this duplication of logic that actually manages the estate and the kind of implementation details of the events that are coming out of the value input. It would be nice if we could pull this logic into the component itself so that we don't have to duplicate. And we can imagine that instead we could just pass a very generic kind of state management function into the into the component instead. So let's change the implementation of the value input to accommodate this more ideal API. So looking at this unchanged callback, instead of a function that takes an event, we're now going to expect a function that takes the string that represents the state that it intends to update, which corresponds in this case to the value prop. Now we're going to need to pull an adapter and we can define in line to pass into the native input instead of the prop directly. So it's a it's a pretty simple adaptive function to define, and then we'll just pass it directly in. Now. Any questions there? Is it because it's only going to get better from here? It doesn't look like anything is in the chat, so I will go on. So now with this new sort of kind of protocol for the expectations of how how props should be organized for our component, we're going to define another component with largely the same structure. And this component is going to be a button component and it will have some behavior where if you hover over it with the mouse, it will show up in a different color and it will also send some events if you click on it. So now, again, side by side, we're going to build both of these components up from scratch and look at the similarities between the two, the two approaches. So first, will the clear functional component in this case, we'll call it basically. Will render out a native button and that will examine the props that we expect this component to mean. We'll start with one label property that will populate the text of the button and then highlight Boolean that we can use to drive some of the button style. So we'll just pass those non callback drops more or less directly into the button. So beyond that, we're going to have some callback props on the button. And for the button, we're going to have to call back handling the mouse center and mouse live events and we expect those to be able to to manage a boolean. Which will correspond to the highlight and also expect an unclick that doesn't particularly do any state management, but it will be able to send a signal to a parent that the button has been. So to accommodate these properties, these callback properties, we're going to need some adapted functions and we'll specify them in line here very simply, taking the onboard center and just leave callbacks and passing in the new value that we expect the highlighted profit to be once we once those events trigger. And then we've also got an adapter for the click as well. And we'll pass those adapters straight into the button and. That's really all there is to the implementation of our component. So a lot of similarity here between disvalue input and the basic button. We've got a set of a set of props, some of which stateful, and we've got a set of callback props that know how to modify those stateful props and adapters that will connect the props that are coming in to the props that the rendered notes actually need. If we want to consume either of these in an application, we'll start again by rendering out the component, but we'll have to set up some props to pass through. So we'll establish a state slice in this case for highlighted boolean state. We'll pass on the highlighted prop and the states that the state setters directly in, and this feels pretty good because we've been able to eliminate a lot of the need to have adapters in at the point of consumption just to get the basic button on screen and functioning in the proper way. But if we wanted to implement this button somewhere else in our application, we can see that we still got some duplication. And it's it's a little bit tricky to suss out what what the challenge is here, but. Basically, what we have is we exposed a lot of implementation details about the nature of the property callback interactions between. Between the props of, of course, the basic button and the value input, so on the left, we've got we kind of have encoded this fact that the value the value prop is going to be modified by the set value or by the unchanged callback. And then on the right side, that this highlighted prop is going to be modified by the center and on leave, call that and it would be nicer if we didn't have to know. We didn't have to know so much about how each component were implemented. And instead, we just know generally we've got some stateful props and generally we're going to have some state handling callbacks that know how to handle those stateful props for the component. This starts to get a little bit cleaner because now there's much less that you actually have to know about how each component. So in order to be pretty agnostic about kind of how of the internal properties of each component relate to the callbacks. And so that's something that we can do by changing, changing the way that we pass testate and those callbacks into the component. So we're going to now make some changes to both of our components to accommodate this new API, so focusing in on the on the callbacks, unchanged for the value input and enter and almost leave for the button. We're now expecting not functions from from individual properties, but instead we're expecting to have functions that expect an updater function instead. And so an updated function, again, is a function that is able to take a prior state to the next. So when it comes to our adaptiveness, we're going to have to make a couple of changes here instead of passing booleans and string's, instead we're going to pass updater functions so functions from one state to in that state, and that will patch either the value or the highlighted state as necessary. And so that's a pretty simple modification to use do. So the last bit is to we've kind of had we had to sacrifice something when we when we make this change to use the updater instead of just passing kind of more direct value, we we've lost this ability for a parent to kind of introspect what events have happened and why they've happened. And so we can get some of this capability back if we change the signature just a little bit more. And so instead of just an updated function, we'll expect two arguments and action, which is capable of describing an event that happened and the updater, which is the default logic for making the property update. So in each of our adapters, we'll add a prefix argument that is an action that describes the nature of speech, so unchanged actions and the value input and then on center on the leave actions for the basic button. And we don't want to leave our unclick method left out, and it's always nice to have a very consistent signature for for your entire kind of property set. So we'll update this to expect an action of sorts as well. And we will pass that action through the Colbert. So this is our component. Now, these are components. Now let's make the changes necessary in the application to support this addition of an additional action. So. We are going to need another set of adaptation functions in order to bridge the gap between the state signature and this action updater Peyer signature that we had before. So we'll add an adapter. We'll call it managed state. It expects, again, those two arguments. And what it will have to do is call state again within with an update of its own that is able to calculate what the next state is. And that can simply be done by calling the updater that we already have. But if we look at the signature, the signature of our Dr.. We see that we've got this leftover action argument that we're not making great use of and anybody who's so much familiar with redacts will see an opportunity to use a use a familiar pattern, which is the reduced pattern. So instead of just having an updated function that takes the state, well, he's a producer that takes a state and an action. And the pair of those will be responsible for creating the next state instead. And this is this is pretty good. Well, we'll also add a guard to make sure that we actually have a reduced producer function because as is the case with our unclick callback, we we may not actually want to update the state at all. And that will pass in those adapted managed state functions into the props. And it turns out that this manic state function will be sufficient to fulfill any callback, any component callback that fits this sort of signature, which is has a lot of kind of benefits, because now we can actually extract a lot of this logic into its own custom. Let's call it use managed state. And the only difference between the adapter that we needed for the value input and the adapter that we needed for the basic button is how it initializes the state and local state slice, which we can actually parameterize in much the same way that you state does. So we'll make a substitution here. There's an opportunity to use callbacks, arrests, rest, so that we get stable values that we can try to reduce the number of reminders. But ultimately, from this custom hook, we'll just return the state and this Menaged state adapter. So going back to our F and if we want to use this hook, instead we get to pull out a lot of we get to pull out a lot of the logic that we had in here. So, again, we'll we'll use our state, hook our use managed state hook with the properly initialise values, and then we can get rid of the adapter altogether. Now, if we're going to use this hook and the and the components in another part of the app, there's still a little bit there's still a little bit of duplication of logic that it would be really nice if we could get rid of because we still have to know the precise names of each of the callbacks that can manage at the end of the day. It would be great if instead we just had by convention a single property that we could pass in that well managed state in the default case for all callbacks. And that's not a terribly difficult thing to do. If you go back to our component implementations and we focus in on our callback properties, we can add another callback property that is just state manager and we'll substitute the state manager in for any callback that does not that does not have an explicitly set property and that will do all of the work for us. Cool. Any questions so far? Let me pull up a chat. Oh, yeah, cool, moving along then in the homestretch, now we've got two components, we're aiming for three. Here's our third component and we'll call this the codecs field. So this is a field that it takes some text and you can encode it and you can decode the text in it. So it combines the two components that we've already developed, two basic buttons and the values input. So now we get into a little bit of. Dealing with the composition of components that use this state management style. So if we were to declare a functional Chodak field a component. We'll just render some gas tax again to two buttons and an input, we're going to need some props to handle all these components because just like our other components in the design system, we want this to be totally controllable. So the props that we might expect are one and the mechanisms by which you would encode and decode the value in the field and then two sets of properties corresponding to each component that that the parent will have to render itself. So field props and coder props and decoder props. OK. Well, plug in those props, we'll pass all of those problems into the children. But we also need to be able to handle the stake for each of those components as well. And so for our field, we'll simplify things a little bit by only having this this single unified channel. But through which events get fired, actions and producers get get sent. And we'll need a set of adapters for each child now. So each of each of these adapters is a state manager in their own right. So a function that takes an action and reduce and is responsible for causing causing its own problems to change. So we'll pass in those state managers into their corresponding children and what actually happens in each of these adapter state managers? What happens is we have to call the state manager from the parent, but we're going to have to adapt, adapt the property dialect from from the from the props that the parent is expecting into the property dialect of the children. So the to to take the field, for instance, the field expects to be describing in its reducer how its value should change. But the Kodak field itself is actually going to have to describe its producer, how the field props cumulatively change. So we'll have to have a special adapter for that. So what we will do is we'll call our state manager. We're calling the parents the state manager. With the same action that we got from the child. But instead of the reducer that we got from the child, which again knows how to talk about how a value should change, how a value proposition change, we're going to pass in our own producer and our own producer is going to. Call the child, reduce her down. Passing in the props that are at the dialect of the child level and the action that came from the child and calculate basically the next props, that would be for the child. Then lastly, it's going to plug in that new set of properties for its title into its cumulative it's cumulative sort of properties that. And the other adapters are actually going to do almost exactly the same thing. Kind of bridging the gap between the reducer that knows the state dialect of the child to the state dialect of the parents. The only difference between these three methods at this point is what part of the parent state gets passed along to the producer. And what part what part of the resulting state gets updated as a result? Which is kind of nice, but the encoders are actually going to need something a little bit more because they also have to handle some other events that don't necessarily update state themselves. The buttons don't know when they click how how state should change as a result, instead of click on the button, intends to change. Ultimately, the field crops that are coming into the Kodak field so will establish a state management reducer for that purpose. There's a lot of code here. The most significant bit is that we'll take the last the last value that came in and encode it and pass it out into the value of the next field. And we'll have to do a very similar thing for the DeCota handling the click on the Decode site, which will just decode the last value to produce the next value, and that will bubble all the way back up through through the producer and come back through its props. So there's a lot going on here, admittedly, this is a lot of code, but the the value here is that now all of this code is contained within the component itself. This is code that you might have had to write yourself if you're hoping to consume this Chodak Fielden, one of your places in your application. But now you're able to reuse a lot of this this code and leverages the fact that there's just kind of a default implementation that each component kind of provides to its parent. So in an app, again, we use that same that same hook, it's the same use Mattituck that we were using in our application usages of the value input and the basic button. And we're just able to kind of naively pass in the state and the state manager and get a component on screen that's just going to work out of the box. Let's pause one more time for questions. It looks like we've got a question here from Howard what are the advantages of this technique if this is used once you run into maintenance issues, when a programmer who's not familiar with this has to fix a problem or update the code? Certainly this is all I will just speed through to the end where I can talk about the benefits and drawbacks of this approach. So the on the benefit side, certainly. It is a systematic play by which you can offer total control of a component and it's got a consistent and uniform API, which makes it really easy for users to consume on the drawbacks side. It is a little bit more involved to produce. And as Howard asked, it is a bit of a familiar pattern. There will be a bit of a learning curve to get used to this kind of thing. But how are to address your question? I think that. This is this is a style that benefits from the convention, and once you know the convention, you can go into any component and expect to see the same. The same patterns existing through throughout all of the components in that are implemented in this way, so it will it will take a little bit of onboarding time to get used to the approach. But once you do, it helps you. I think that you'll actually get benefits out of being able to make changes quickly. Certainly another of the drawbacks is that there's still a lot of as a novel approach, there's a lot of room for improvement. I already have some ideas about how to clean things up. So there is a chance that this might not be a thing that everybody should just go out and adopt today. Again, the thing the things to really take from from the talk are these two kind of ideas that the producer of. That the state doesn't have to the logic for the state does not have to live where the state is actually staged and that the that a child can communicate to its parent kind of what the default implementation of what it expects as a default implementation that can be overridden, as you might want to do later. So. Things to look at in the future, how to make it actually more ergonomic to compose components in this style together. There are a lot of ideas that I have there to kind of reduce the amount of boilerplate, ultimately, that you have to go through to set one of these up. There may be an opportunity as well. At MediaMath, we have used Soga to some effect and being able to handle kind of asynchronous side effects in a component architecture like this may be of some benefit, or if you have to manage like timing of animations that need to to be managed. And then lastly, I've been keenly interested in trying to figure out ways to to use state charts or finite state machines in a way to ultimately be able to have more reliable component infrastructure and then ultimately be able to visualize how a component system can interoperate. Because I think those sorts of tools make it a lot easier for people to learn how how the pieces all fit together. With that, I'd like to thank my colleagues at MediaMath who who give me give me the space to explore some of these novel ideas, which I really appreciate and putting up with my shenanigans. And then I'll ask for any last questions one last time. Do you have any goals for open source or maintaining this or publishing in? I do have I do have goals. This is about this is about a 70 percent solution to to the problem and I am in the process of working on the next 20 percent. But I will say that you don't need you don't need much to leverage the pattern itself, as you've seen, we are kind of able to bootstrap the whole thing right now. The the the shareable bit is really in that year's managed. In this used managed state hook, this is the sheriff bit and then ultimately boils down to something like 20 lines of code. That I would encourage anybody to even try to write for themselves, have a better understanding of how that awkward scenario. So, yeah, this used Usmani state is really the that's the kernel of this terrible logic, everything else comes down to kind of a convention, the convention by which you design your component APIs so that they are controllable in a consistent and kind of modular way. I have another question in the chat. I like the consistency of the API, but doesn't this approach separate the state of the component from the component? Which is. This was more like talking about how I like to keep my business logic with my money market, and you're doing that, but I needed to get to the point where, like, putting those states is different than actually storing the state. Yeah, and that was nonintuitive. So that's that is that is the key. Yeah. That is that's the key to this approach, is that you still get you get kind of get to have your cake and eat it too a little bit. You can keep all that logic right next to the component where it's easy to draw the connection. Like one of the challenges that I've had with Redox at times is that you're jumping through a lot of different hoops to find out when you dispatch one action. How does that actually affect the thing that is going to get fed back into the it? And so you're jumping through like four or five different files to to try to connect those dots. But with this approach, you can have the logic, like in many of the components that we're using in our design system internally. We'll have a producer defined right there in the file next to the component that gets exported. And we just pass that reducer along with every action. Thank you. Now I want to ask, are you open to questions through emails, because I think I'm going to have to look through the recording a couple of times just to get like just to know what questions I need to ask. Absolutely. If you are so inclined, you can email me at Justin Justin and talk more about. I'll throw that in the chat. Awesome, thank you. So there are no other questions, thank you. Thanks, everybody, for your time and your patience while my computer was feeling a little finicky.