An angle on React state you haven't seen before...

Mike Talbot ⭐ - Jun 29 '20 - - Dev Community

TL;DR

  • Managing state in React has all sorts of solutions from useContext and useState to Redux. However, all of these methods lack a truly declarative style.
  • I wanted a cleaner way to write code that manages global and document level scoped state. I also wanted a way to write code to update state where it makes the most sense to me - next to the thing it affects. Writing code in this way enables better modularisation and separation of concerns.
  • I like data binding, I like having components bound to parts of the model that can automatically react to changes, it reduces boilerplate code and reduces re-renders.
  • I've built a brand new library, documentation, and a demonstration that you can use to explore the solution that I arrived at given the objectives above. The library makes extensive use of Hooks. react-bound-state is MIT licensed.
  • The react-bound-state library is based on the live version in the commercial software I build for a day job but it has been refined, simplified, and improved.
  • I go on to describe how to use the technique and how it works internally below.
  • The library GitHub page provides access to the API documentation.

Demo

The demo looks better in full-screen mode

The demonstration logs when core components are redrawn, as you will see, redraws are kept to a bare minimum.

Why?

I build apps that predominantly manage documents, and I want to write declarative code that follows SOLID principles. Adhering to SOLID principles ensures that the solutions I make can be easily maintained, developed, and extended.

My projects frequently use React and are modern enough to be almost 100% hook based.

In many web apps and systems, a single document or state will contain information that needs to be handled by many specialized components; this lends itself to creating multiple modules that participate in the editing and rendering processes. Furthermore, the same information frequently needs to be rendered by different components in alternate ways.

I don't like to centralize the state management, because that makes the code very coupled and limits the ability for multiple developers to work on the same code base and reuse previously created elements.

Hooks are great, but they present challenges at both the individual component level and across a broader hierarchy.

  • useState needs to be isolated to a component which can cause multiple re-renders
  • useReducer can also cause large parts of the interface to require updating
  • useContext is a blunt tool that elegantly facilitates static contextual information, but does not cope well with informing sub-elements of an interface to redraw when their state changes.

function Details({onEdited, name, description}) {
      const [currentName, setName] = React.useState(name)
      const [currentDescription, setDescription] = React.useState(description)
      return <div onBlur={()=>onEdited(currentName, currentDescription)}>
            <input value={currentName} onChange={e=>setName(e.target.value)}/>
            <textarea rows={10} value={currentDescription} onChange={e=>setDescription(e.target.value)} />
      </div>   
}

Enter fullscreen mode Exit fullscreen mode

^ BAD: this is complex and intricate; I'd rather avoid it

The above code renders the whole component each time with a currentName or currentDescription change. While this is insignificant here, it's part of a broader problem. In addition to that, the state must be passed into this component as a whole.

I like the principles of data binding offered by Angular, Svelte, and the like, so I've built something that solves all of these problems.

react-bound-state

The new library works by using Inversion of Control implemented using a super-fast wildcard event emitter and lets us:

  • Create multiple global state contexts
  • Bind contexts to objects
  • Have any number of components be updated when relevant state changes, but only those components
  • Create components that are bound directly to the data model
  • Composes in a very straightforward and obvious way

Using the library, we can rewrite Details from above like this:

function Details() {
      return <div>
         <Input property="name" />
         <Input multiline property="description" />
      </div>    
}
Enter fullscreen mode Exit fullscreen mode

^ GOOD: the intent of this code is obvious and clean

Working with state in react-bound-state

The first step is to create state contexts that you will then bind to objects. You will typically make a global state context for each of the things that might be in scope at the same time. In the example, we have a global state for the current document and the current settings.

import {createState} from 'react-bound-state'

export const globalState = createState("global")
export const styleState = createState("style")
Enter fullscreen mode Exit fullscreen mode

Binding Components

Once we've created state, we can access the methods of the state to do some useful things. For instance, we can use bind to create data-bound versions of standard components that we can use when building out our interface.

import {TextField} from '@material-ui/core'

export const Input = globalState.bind({
    component: <TextField variant={"outlined"} fullWidth />
})
Enter fullscreen mode Exit fullscreen mode

The code above creates an component that uses an outlined, full-width TextField that will be bound to the current target of globalState.

The default version of bind expects an onChange event that supports event.target.value or value directly and an input that wants value. We can override all of these. For instance, to create a Combo Box for the demo we use Material UI Autocomplete - this passes the current value of the component as a second parameter to onChange so we might declare the Combo like this:

export const Combo = styleState.bind({
    extract(_, value) {
        return value
    },
    component: (
        <Autocomplete
            options={[]}
            fullWidth
            renderInput={(params) => (
                <TextField {...params} variant="outlined" />
            )}
        />
    )
})
Enter fullscreen mode Exit fullscreen mode

Here we use the extract method that allows us to extract the current value from the onChange event.

We have total control over the attribute that is used, the event that is fired on a change, plus we can also use transformIn and transformOut to convert data from the model to the editing component.

The real benefit is we can apply any properties to the component or the underlying component on the definition or use.

    <Input property="some.model.property" transformIn={v=>v.toUpperCase()} transformOut={v=>v.toLowerCase()}/>
Enter fullscreen mode Exit fullscreen mode

We can also bind components inline rather than defining them up front, using the <Bound/> component exposed from the state:

    <globalState.Bound component={<Checkbox color="primary"/>} attribute="checked" property="done"/>
Enter fullscreen mode Exit fullscreen mode

Binding the state to an object

Before we can do anything much with the components or the other properties of the state, we must bind it to a model.

const App = () => {
    return (
        <styleState.Bind target={styles}>
            <globalState.Bind onChange={save} target={example}>
                <Box mt={6}>
                    <Container>
                        <Example/>
                    </Container>
                </Box>
            </globalState.Bind>
        </styleState.Bind>
    )
}
Enter fullscreen mode Exit fullscreen mode

Here we've bound globalState to a document and styleState to a set of style settings. Once bound, we can start to use the other features of the library to access values, or we can use the bound components to edit values.

You can see the onChange() handler which is called any time any property of the model changes. In the demo case, it is wired to a debounced function that stores the current state in localStorage.

Accessing properties of the state

Once bound, we can use the functions of the state and property syntax to retrieve values, set values, and be refreshed when the value changes, no matter what made the change.

To perform this, we use the following functions:

  • useState provides access to a property of the model and causes the component to redraw should it change, it also works the same way as React.useState and provides a setter function.

  • useBinding provides an object that can be spread onto a component to provide data binding (same as the components and bind() derivation function)

  • useSetter provides a method to set values for a property but does not re-render when it changes

  • useRefresh allows the component to refresh based on defined properties (this includes wildcards)

All of these methods end up using property syntax:

    // Access and set a name property
    const [name, setName] = globalState.useState("some.sub.object.name")

Enter fullscreen mode Exit fullscreen mode

Property syntax works the same way as lodash/underscore/sugarjs get/set methods. We can replace array accessors [] with . if we like.

Binding using property syntax

To facilitate a much easier interface constructor, the <Bind/> component also allows us to use property rather than target to focus on a subsection of the model.

            <Grid item md={3}>
                <Bind property={"profile"}>
                    <Profile />
                </Bind>

                <Description />
            </Grid>
Enter fullscreen mode Exit fullscreen mode

Once this sub binding is made, all of the inner functions use property syntax from the new target.

When we bind to a part of the model that is an array, the children of the <Bind/> will be rendered for every member of the collection.

export function Todos() {
    const setTodos = globalState.useSetter("todos")
    return (
        <Box>
            <Box ml={2}>
                <Typography variant={"h6"} component={"h1"} gutterBottom>
                    Todo List
                </Typography>
            </Box>
            <Box width={1} clone>
                <List>
                    <Bind property={"todos"}>
                        <Todo />
                    </Bind>
                </List>
            </Box>
            <Box mt={2}>
                <Button color={"primary"} onClick={add}>
                    + Add Todo
                </Button>
            </Box>
        </Box>
    )

    function add() {
        const newTodo = prompt("What must you do?", "")
        if (newTodo) {
            setTodos((prev) => [
                ...prev,
                { title: newTodo, description: "", done: false }
            ])
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Here we bind the list of todos. Also, note how we use useSetter because the <Bind/> component knows to re-render all of the Todos when the array changes. There is no need to re-render this component. Similar to React.useState, we can either pass a value to the set function or a function that will receive the previous state; this significantly reduces re-renders.

A child rendered by an array can tell its index using the useIndex() function exported directly from 'react-bound-state.'

Sometimes in an inner component, we might want to reaccess the outer context. For instance, in the demo, a <Todo/> wants to delete itself from the array of todos.

When we have <Bind/> inside of <Bind/> as in this example, we can prepend ^ characters to property syntax to move us up to the parent.

function Todo() {
    const [title, setTitle] = globalState.useState("title")
    const [done] = globalState.useState("done", false)
    const [todos, setTodos] = globalState.useState("^todos")
    const [me] = globalState.useState()
    return (
        <ListItem>
            <Card className="full-width" variant={"outlined"}>
                <CardHeader
                    title={title}
                    action={
                        <globalState.Bound
                            property={"done"}
                            component={<Checkbox color={"primary"} />}
                            attribute={"checked"}
                            extract={(e) => e.target.checked}
                        />
                    }
                />
                <CardContent>
                    <Input label="Notes" multiline property={"description"} />
                </CardContent>
                <CardActions>
                    <Button color={"secondary"} onClick={remove}>
                        Delete
                    </Button>
                </CardActions>
            </Card>
        </SortableItem>
    )
   function remove() {
        setTodos(todos.filter((t) => t !== me))
    }
Enter fullscreen mode Exit fullscreen mode

Here we can see that the <Todo/> accesses its state to render the card but also accesses the todos from the parent state and uses them to delete entries when the user requests it.

Refreshing

The elements of the UI redraw if we have used a bound component or a useState(), and the property or any of its direct ancestors change.

e.g. some.sub.module.name will redraw if some or some.sub or some.sub.module or some.sub.module.name change. It won't redraw if some.sub.another changes of course.

Sometimes, especially if we are retrieving an object (say a style), we may want to redraw if a sub-property of the thing we have retrieved changes. This is what useRefresh() is for and, in that case, we can also use wildcard * and ** characters in the property syntax:

     const [style] = styleState.useState("some.style")
     styleState.useRefresh("some.style.**")
     return <div style={{...style}}><Content/></div>

Enter fullscreen mode Exit fullscreen mode

The code above redraws the component if any sub-property of the style changes.

Setting data

useSetter() and useState() both return functions that allow you to set the value of a part of the model. You may either mutate or use immutable data as you wish. The set functions returned by both also have a .set() sub-function that will set multiple properties at once from an object. These are merged into the current state.

     const updateStyle = styleState.useSetter("style")
     // Merge properties into the style using .set()
     updateStyle.set({background: "red", color: "white"}) 

     // Replace the whole style object 
     updateStyle({color: "blue"})
Enter fullscreen mode Exit fullscreen mode

How it works

The whole system is predicated off React hooks combined with a super-fast wildcard based, custom event emitter. The system uses Inversion of Control to announce updates to the model and then it loosely couples listeners that react and cause the relevant parts of the UI to redraw.

The current value of a <Bind/> component is stored in a React Context, and useState is used as a refresh mechanism when we discover a change. We use the useEffect hook to wire up the relevant event handlers and ensure that they are correctly disposed of.

useState(property = "", defaultValue, target) {
        let { target: existingTarget, path, stack } = this[useTargetContext]()
        target = target || existingTarget
        ;[property, target, path] = getTargetFrom(property, target, path, stack)
        const value = get(target, property, defaultValue)
        const [id, refresh] = useState(-1)
        const currentRefresh = useRef()
        React.useEffect(() => {
            return () => {
                currentRefresh.current = ()=>{}
            }
        }, [])

        currentRefresh.current = refresh
        useEvent(getPatterns(target, [...path, ...getPath(property)]), update)
        return [value, updateValue, id]

        function update() {
            currentRefresh.current(refreshId++)
        }

        function updateValue(newValue) {
            if (typeof newValue === "function") {
                newValue = newValue(get(target, property, defaultValue))
            }
            set(target, property, newValue)
            emit(target, path, property, newValue)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Here you can see the useState hook from the system. It uses getTargetFrom() to handle upward movements through the state stack. get() is an implementation that can retrieve a value from an object without throwing an error if parts are missing.

get() is paired with set() which does the same for setting a property (these are very like lodash/underscore).

The useEvent() hook uses React.useEffect to wire up the set of handlers suggested by getPatterns()

The code in useState is pretty much the crux of the whole thing. When we change a value, we update the model and emit an event. Any component that cares about the property (or is derived from it, or is listening for it with useRefresh()) will redraw retrieving the latest updated value.

function Bind({ target, property = "", onChange = () => {}, children }) {
    const self = this
    const innerId = React.useRef(refreshId++)
    let { target: existingTarget, path, stack } = this[useTargetContext]()
    if (target && !targetIds.has(target)) {
        targetIds.set(target, nextId++)
        path = [`${targetIds.get(target)}`]
    } else if (target) {
        path = [`${targetIds.get(target)}`]
    } else {
        target = existingTarget
    }
    const [finalTarget, setFinalTarget] = React.useState(target)
    const currentTarget = useRef()
    currentTarget.current = setFinalTarget
    React.useEffect(() => {
        return () => {
            currentTarget.current = noop
        }
    }, [])
    useEvent(`${targetIds.get(finalTarget)}`, update)
    let updatedPath = [...path, ...getPath(property)]
    useEvent(
        getPatterns(finalTarget, updatedPath).map((p) => `${p}.**`),
        () => onChange(finalTarget)
    )
    const [subTarget, , , id] = this.useState(property, {}, finalTarget)
    if (Array.isArray(subTarget)) {
        return <ArrayContents key={id} />
    } else {
        if (typeof subTarget !== "object")
            throw new Error("You must bind to an object or an array")
        return (
            <this.context.Provider
                key={`${id}:${innerId.current}`}
                value={{
                    target: subTarget,
                    path: updatedPath,
                    stack: [...stack, { target: subTarget, path: updatedPath }]
                }}
            >
                {children}
            </this.context.Provider>
        )
    }

    function update(newValue) {
        targetIds.set(newValue, targetIds.get(target))
        innerId.current = refreshId++
        currentTarget.current(newValue)
    }

    function ArrayContents() {
        let output = []
        for (let i = 0; i < subTarget.length; i++) {
            output.push(<Item key={i} index={i} />)
        }
        return output
    }

    function Item({ index }) {
        return (
            <IndexContext.Provider value={index}>
                <self.Bind property={`${property}.${index}`}>
                    {children}
                </self.Bind>
            </IndexContext.Provider>
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Bind, on the other hand, handles using an object, which it stores in the context, in addition to providing the facility for notifying its owner when things have changed.

Conclusion

I've presented a compelling way of binding data to "contextual" and global scope. It composes in a very natural way and has the additional benefit of providing data binding for React components. As the demonstration project proves, redrawing is minimized.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player