6

A mini-redux in React

 1 year ago
source link: https://mortoray.com/a-mini-redux-in-react/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

A mini-redux in React

Several stacks of card drawers

Redux offers a state-based approach to storing data, yet can be an unwieldy tool in many situations. The good news is: React hooks make it simple to build your own Redux-like store. Your own version can be tailored to your needs.

I’ve created these stores on multiple projects and want to share my experience.

  • Uncountable: At Uncountable, some of our forms were doing too many React updates, causing a noticeable performance degradation as the user typed. I created a small derivative store that allowed listeners to subscribe to a dynamic list of variables and prefixes.
  • Edaqa’s Room: I have a passion for escape rooms and have built a series of virtual escape rooms for people to enjoy remotely. These are point-and-click style puzzle games. The multiplayer game engine uses Websocket and messages to maintain a distributed state – each user will have a complete copy on their machine. I wanted to hook into this state using redux-like selectors.

We’ll use the escape room game context to explore some examples for setting up these state updates.

A Context Class

The UI layer for my game works much like any other react app. For example, I have a panel showing the players’ names and location in the game. To get this information I’d have a redux-like selector, sort of like useSelector ( gameState => gameState.players ), and then use a .map to create an HTML listing. The code would be familiar to anybody using React, with or without Redux.

For the code, I’ll try isolating the most relevant bits, and adjust symbols to make more sense outside of the full context. This won’t be a complete example, but should cover all the bits needed to make your own.

The first step is making a class which will be the store manager. This class keeps a list of listeners and calls them when the state changes. In my game, this is called the GameContext, which connects the network-based state to the listeners.

The key listening function is listen_game_state: a listener grabs the context, and inside a useEffect call adds their own listener.

export class GameContext implements StateChanged {
	gsl_id = 0
	game_state_listeners: Record<number,game_state_listener> = {}
	
	...

	listen_game_state( listener: game_state_listener ): ()=>void {
		this.gsl_id += 1
		const nid = this.gsl_id
		this.game_state_listeners[nid] = listener
		
		return () => {
			delete this.game_state_listeners[nid]
		}
	}

Whenever the state changes, the store calls all the listeners with the updated state.

	game_state_changed(game_state) {
		unstable_batchedUpdates( () => {
			for (const listener of Object.values(this.game_state_listeners)) {
				listener(game_state)
			}
		} )
	}

In my game, this class only manages the listeners, not the state itself. At Uncountable, I have a similar class that directly manages the state. I’ll come back later to one possibility for how to manage this. For now, all we need to know is that something triggers a call to game_state_changed.

The unstable_batchedUpdates function is effectively undocumented; I found it on some development mailing lists. Its purpose is to allow a lot of things to update state at once, prior to triggering a re-rendering after each individual update. This is helpful, since with a global state you may have many components that update in response to a change.

In React 18 it appears unstable_batchedUpdates is no longer required.

Registering

Users of the store need some way to know about it. In React, contexts serve this purpose –– which is primarily why my store class has “Context” in its name.

The context identifier is declared globally.

export const GameContext = create_context<GameContext>()

Setup of this context is like any other. In my case, I create an instance of the store in the function that initiates my React app: the one that calls ReactDOM.render. Distilled to the essential bits, it looks like below.

_global.create = ( element: HTMLElement, config: GameConfig ) => {
	let game_store = new GameContext(config)
	ReactDOM.render(
		<ErrorBoundary>
			<GameContext.Provider value={game_store}>
				<LayoutComponent />
			</GameContext.Provider>
		</ErrorBoundary>,
		element
	)
}

This creates my context globally within the root of the app. At Uncountable, we use local contexts, so different parts of the same app use different mini-reduxes. That can be done with a memo that never reevaluates. Here’s an example of how I would set up my game that way instead:

function StoreWrapper(props: { config: GameConfig }) {
	const game_store = useMemo(() => new GameContext(config), [config])

	return (
		<ErrorBoundary>
			<GameContext.Provider value={game_store}>
				<SomeComponent />
			</GameContext.Provider>
		</ErrorBoundary>,
	)
}

Be aware that technically useMemo could evaluate even if the inputs don’t change. It never seems to though. In the cases I’m showing here, it would be fine if it did, it’d merely be inefficient. I have an unrelated use-case that requires writing a useGuaranteedMemo function, just to be sure.

Listening

In the below code, I show an approach to binding a component to the store. It grabs the store from the context, adds a listener, and updates that local React state with the player’s name:

function SampleGameListener() {
	const game_store = GameContext.use()
	
	const [name, set_name] = useState("")
	
	RB.useEffect( () => {
		return game_Store.listen_game_state( (game_state: GameState) => {
			set_name( game_state.player.name )
		})
	}, [game_store])
	
	return <p>{name}</p>
}

It has a few problems though.

First, that’s a lot of code to express a simple concept. We’d hate to write that for every component that wants to watch the global state.

Second, it doesn’t do any kind of filtering on the state, calling set_name for any change at all, whether the player's name was changed or not. I believe useState is optimized to do nothing if the value doesn’t change, so in this case it may be okay. But for any code that doesn’t use primitives, like a string, or returns dynamic values, we might get an unwanted rerender.

Redux offers selectors to solve both problems. Instead of listening to every state change, we look at only a piece. In our name example, we’d only be interested in changes to the player’s name.

For my game, I exposed this concept in a function called useGameState, which takes a selector and returns only that part of the state:

/*
Will only update if the selector returns a different value. Comparison is object identity plus
a shallow array compare (making it suitable to return several bits from the selector)
*/
export function useGameState<T>( gs : game_selector<T> ): T {
	const ctx = GameContext.use()
		
	const [ state, set_state ] = React.useState<T>(():T => gs(ctx.current_game_state()))

	React.useEffect(() => {
		const track = {
			current: state,
 		}
		
		return ctx.listen_game_state( (game_state: GT.game_state) => {
			const next: T = gs(game_state)
			const same = Array.isArray(next) ? array_equal(next, track.current) : track.current == next
			if (!same) {
				track.current = next
				set_state(next)
			}
		})
	}, [ctx, set_state, gs])
	
	return state
}

That’s a bit of a leap in complexity, so let’s look at how to use it first. We can rewrite the former component as follows:

function SampleGameListener() {
	const name = useGameState( gs => gs.player.name )
	return <p>{name}</p>
}

That’s much cleaner than before!

The selector is the gs => gs.player.name function. Selectors are functions that take the full state and return only a part of it.

These selectors allow the useGameState function to avoid redundant updates. Notice how it uses the track.current value to compare a new evaluation to the old one. If the value hasn’t changed, then the React set_state function will not be called.

I get better control over what happens now. Besides simple primitive comparisons, I added an array_equal comparison. This allows the selectors to return a set of values and not worry about an update if all the constituents are the same.

const [name, score] = useGameState( gs => [gs.player.name, gs.currentScore] )
return <p>{name}: {score}</p>

In one of my stores, I’ve extended the selectors to do a shallow comparison of objects as well. That comes with its own overhead, but it’s much cheaper than the rendering overhead that would happen otherwise. The “reselect” package for Redux also provides different comparisons, as well as more advanced selection cases, such as derived selectors. I didn’t need those in my game, but if you follow a similar pattern of tracking previous results, you can see how to implement them.

Dynamic Listening

At Uncountable, I’m working with user-configurable forms; I wrote a mini-redux to deal with dynamic listening requirements. One of the biggest limitations of React and Redux is the expectation that a component listens to a fixed set of variables. You can’t, for example, use hooks inside a conditional or loop.

I created a dynamic form system, where individual fields needed to listen to multiple values in the form. Whether one field is editable, or visible, may depend on the value in some other field. Another feature is to populate one field with a value derived from one or more other fields.

The state of the form is represented by a simple object, where each field has a reference name. This is a small example of such an object — actual forms may have hundreds of fields spread over several pages!

{
	name: "Aspen",
	age: 34,
	location: "Melbourne",
	region: "au",
}

In my initial version, I had each component directly listen to all data updates, but that was causing a noticeable performance issue. I needed a fix.

The logic is complicated, so I’ll distill it down to its essence. For any given field, I need to listen to a variable amount of form values.

function DisplayFormComponent(props: { field: FieldConfig }) {
	const listenTo = useMemo( () => [
		field.primeReference,
		...getConstraintReferences(field),
		...getCalculationReferences(field),
	], [field] )
	
	const fieldValues = useFormContextAll( listenTo )
	...
}

I first assemble the list of all values I need to listen to. This is suitable for a useMemo call, as field is a stable value. Then I use the theoretical useFormContextAll to listen to changes to any of those values. The useFormContextAll function has a similar structure to the useGameState function. Indeed, you can imagine a nearly identical function with only the const next assignment line replaced.

	const next: T = listenTo.map( name => gs[name] )

The shallow array comparison will prevent updates if none of the values have changed.

This form system evolved independently and actually uses a rather different approach. It’s not using functional selectors, but subscriptions are by name, or prefix. This works since the state is a flat object. The result is roughly the same though.

Custom stores

A custom store can be more flexible than Redux, which comes with its own limitations on how data can be structured. I wrote one of the stores because I needed to optimize React updates. The other I wrote since I wanted a small solution, and one that integrates with a network protocol. The code is small and clear.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK