A library for writing better React components and data stores. Uses automatic subscriptions to reduce code and avoid common data flow pitfalls. Scales for projects of all sizes and works great with TypeScript.
For the 2.x+ ReSub releases, to get in line with the apparent future of React, we have changed how ReSub uses the React lifecycle functions to instead use the getDerivedStateFromProps path. See this link for more details on the new lifecycle. This means that, for the standard happy path of just using _buildState, everything should keep working just like it used to. However, if you were using the old React lifecycle functions (componentWillMount, componentWillReceiveProps, etc.) in any way, you will likely find those functions not being called anymore. This change should future-proof ReSub for a while, and play more nicely with the future async rendering path that React is moving toward.
As a small secondary note, for 2.x+, we also removed support for the old-school manual subscriptions. We strongly suggest moving to either autosubscriptions (where ReSub's true differentiating value is) or, if you prefer manual subscriptions, directly subscribe to the stores in your component using whatever lifecycle you prefer.
All in all, for more complicated projects especially, these may be substantial changes to your usage of ReSub, and upgrading may be hard. You're obviously welcome to keep using the 1.x branch of ReSub indefinitely, and if there are bugs, please let us know and we will attempt to fix them, but we won't be putting any more energy into features/examples/etc. on 1.x and should consider it to be on LTS at this point, since it doesn't work with versions of React newer than 16.8.6.
In React’s early days, Flux gave us guidance on how to manage data flow in our apps. At its core, data would be placed into stores and React components would fetch it from them. When a store’s data was updated, it would notify all concerned components and give them the opportunity to rebuild their states.
While Flux works well, it can also be cumbersome and error prone. Separate actions, action creators, and stores can result in a great deal of boilerplate code. Developers can fetch data from a store but fail to subscribe to changes, or components can oversubscribe and cause performance issues. Furthermore, developers are left to implement these patterns from scratch.
ReSub aims to eliminate these limitations (and more) through the use of automatic data binding between stores and components called autosubscriptions. By using TypeScript’s method decorators, ReSub components can subscribe to only the data they need on only the stores that provide it, all without writing any code. ReSub works with both traditional class components as well as function components, which is the direction that React seems to be heading for most usage.
The easiest way to understand ReSub is to see it in action. Let’s make a simple todo app.
The heavy lifting in ReSub is done mostly within two classes, ComponentBase and StoreBase. It’s from these that we make subclasses and implement the appropriate virtual functions.
First, we create a store to hold todos:
import { StoreBase, AutoSubscribeStore, autoSubscribe } from '@boatkit-io/resub';
@AutoSubscribeStore
class TodosStore extends StoreBase {
private _todos: string[] = [];
addTodo(todo: string) {
// Don't use .push here, we need a new array since the old _todos array was passed to the component by reference value
this._todos = this._todos.concat(todo);
this.trigger();
}
@autoSubscribe
getTodos() {
return this._todos;
}
}
export = new TodosStore();Next, we create a component to display the todos:
import * as React from 'react';
import { ComponentBase } from '@boatkit-io/resub';
import TodosStore = require('./TodosStore');
interface TodoListState {
todos?: string[];
}
class TodoList extends ComponentBase<{}, TodoListState> {
protected _buildState(props: {}, initialBuild: boolean, incomingState: {} | TodoListState): TodoListState {
return {
todos: TodosStore.getTodos()
}
}
render() {
return (
<ul className="todos">
{ this.state.todos.map(todo => <li>{ todo }</li> ) }
</ul>
);
}
}
export = TodoList;That’s it. Done!
When future todos are added to the TodoStore, TodoList will automatically fetch them and re-render. This is achieved because TodoList._buildState makes a call to TodosStore.getTodos() which is decorated as an @autoSubscribe method.
ReSub is built with scalability in mind; it works for apps of all sizes with all scales of data traffic. But this doesn’t mean scalability should be the top concern for every developer. Instead, ReSub encourages developers to create the simplest code possible and to only add complexity and tune performance when it becomes an issue. Follow these guidelines for best results:
- Start by doing all your work in
_buildStateand rebuilding the state from scratch using autosubscriptions. Tracking deltas and only rebuilding partial state at this stage is unnecessary for the vast majority of components. - If you find that components are re-rendering too often, introduce subscriptions keys. For more information, see the “Subscriptions by key” and “Subscriptions by props” sections below.
- If components are still re-rendering too often, consider using trigger throttling and trigger blocks to cut down on the number of callbacks. For more information, see the “Trigger throttling” and “Trigger blocks” sections below.
- If rebuilding state completely from scratch is still too expensive, manual subscriptions to stores (
store.subscribe()) with callbacks where you manage your own state changes may help.
By default, a store will notify all of its subscriptions any time new data is available. This is the simplest approach and useful for many scenarios, however, stores that have heavy data traffic may result in performance bottlenecks. ReSub overcomes this by allowing subscribers to specify a string key that limits the scope in which they will trigger.
Subscription keys must match field paths on the store state. ReSub still passes keys around as strings at runtime, but every fixed trigger/subscription key must name a field on this._state. There is no upper camel case requirement and no Key_ prefix requirement. The only special key is StoreBase.Key_All, which ReSub uses internally for subscriptions that should respond to every trigger. Use the resub/state-keypaths ESLint rule to enforce this requirement.
Consider an example where our Todo app differentiates between high and low priority todo items. Perhaps we want to show a list of all high priority todo items in a HighPriorityTodoItems component. This component could subscribe to all changes on the TodosStore, but this means it’d re-render even when a new low priority todo was created. That’s wasted effort!
Let’s make TodosStore smarter. When a new high priority todo item is added, it should trigger with a custom key instead of using the default StoreBase.Key_All key. Our HighPriorityTodoItems component can now subscribe to just this key, and its subscription will trigger whenever TodosStore triggers with either that exact key or StoreBase.Key_All, but not for unrelated keys.
All of this can still be accomplished using method decorators and autosubscriptions. Let’s create a new method in TodosStore:
class TodosStore extends StoreBase {
...
private _state = {
highPriorityTodos: [],
};
@autoSubscribeWithKey("highPriorityTodos")
getHighPriorityTodos() {
return this._state.highPriorityTodos;
}
addHighPriorityTodo(todo) {
this._state.highPriorityTodos.push(todo);
this.trigger("highPriorityTodos");
}
}Note: Of course it’s possible to separate high and low priority todo items into separate stores, but sometimes similar data is simultaneously divided on different axes and is therefore difficult to separate into stores without duplicating. Using custom keys is an elegant solution to this problem.
Because keys must match state field paths, use the field name directly when subscribing to a top-level _state field:
class DeviceStore extends StoreBase {
@autoSubscribeWithKey("devicePGNs")
getAllDevicePGNs() {
return this._state.devicePGNs;
}
setDevicePGNs(devicePGNs) {
this._state.devicePGNs = devicePGNs;
this.trigger("devicePGNs");
}
}The string "devicePGNs" matches the devicePGNs field on this._state; using "devicepgns" or "Key_DevicePGNs" would not match this state field path.
Key-based subscriptions are very powerful, but they can be even more powerful and can reduce more boilerplate code when combined with autosubscriptions. ReSub uses standard TypeScript decorators, so @key is a method decorator that records which argument positions are part of the subscription key. Let’s update our TodosStore to add @key(0):
class TodosStore extends StoreBase {
...
@autoSubscribe
@key(0)
getTodosForUser(username: string) {
return this._todosByUser[username];
}
}Now, we can establish the autosubscription for this user in _buildState:
class TodoList extends ComponentBase<TodoListProps, TodoListState> {
...
protected _buildState(props: {}, initialBuild: boolean, incomingState: {} | TodoListState): TodoListState {
return {
todos: TodosStore.getTodosForUser(this.props.username)
}
}
}_buildState will be called when TodoStore triggers any changes for the specified username, but not for any other usernames.
Sometimes a subscription key needs to interleave fixed state-field names with values from a method's arguments. Standard decorators cannot be attached directly to method parameters, so use keyArg(index) inside a keyPath(...) descriptor:
class RadarStore extends StoreBase {
@autoSubscribeWithKey(
keyPath('radars', keyArg(0), 'dataSets', keyArg(1), 'spokeData'),
)
getSpokeData(radarID: string, dataSetKey: string) {
return this._state.radars[radarID].dataSets[dataSetKey].spokeData;
}
}Calling getSpokeData('radar-1', 'primary') subscribes to radars%&radar-1%&dataSets%&primary%&spokeData. Fixed strings and numbers remain literal path segments, while keyArg(0) and keyArg(1) look up those positions in the method's runtime argument list. Referenced arguments must exist and be typed as string or number.
autoSubscribeWithKey is variadic, so one getter can subscribe to multiple independent paths:
@autoSubscribeWithKey(
keyPath('radars', keyArg(0), 'dataSets', keyArg(1), 'rangeMeters'),
keyPath('radars', keyArg(0), 'spokeRangeMeters'),
)
getRangeMeters(radarID: string, dataSetKey: string) {
// ...
}Simple fixed subscriptions retain their shorter form: @autoSubscribeWithKey('radars'). Arrays such as @autoSubscribeWithKey(['boxA', 'boxB']) also continue to subscribe to multiple fixed keys.
The older @key(0, 1) form remains supported for argument-only compound keys and for appending arguments before a fixed @autoSubscribeWithKey suffix. New hierarchical state paths should prefer keyPath, which can interleave fixed and dynamic segments in their actual order.
To trigger these compound keys, you execute this.trigger(ReSub.formCompoundKey('key1val', 'key2val', 'autoSubscribeWithKeyval')) and it will trigger the key to match the autosubscription of your function. formCompoundKey does not understand object paths or wildcards; it simply joins the key segments into one exact string. When dynamic method arguments represent map/index values, the fixed string segments should still match fields from the state path.
NOTE: Compound keys themselves don't actually support any sort of hierarchy. If you don't trigger EXACTLY the correct key, your subscriptions will not update. If you have a key of ['a', 'b', 'c'], and you trigger ['a', 'b'], you will be disappointed to find that none of your subscribed components update. Compound keys are designed to help you provide discrete updates within a hierarchy of data, but are not designed to allow for updating wide swaths of that hierarchy.
Example of correct usage:
enum TriggerKeys {
BoxA = 'boxA',
BoxB = 'boxB',
}
class UserStuffStore extends StoreBase {
private _state: {
stuffByUser: {[userCategory: string]: {[username: string]: {boxA: string; boxB: string;}}}
};
@autoSubscribeWithKey(TriggerKeys.BoxA)
@key(0, 1)
getBoxAForUser(userCategory: string, username: string) {
return this._state.stuffByUser[userCategory][username].boxA;
}
@disableWarnings
setBoxAForUser(userCategory: string, username: string, boxAValue: string): void {
this._state.stuffByUser[userCategory][username].boxA = boxAValue;
this.trigger(ReSub.formCompoundKey(userCategory, username, TriggerKeys.BoxA));
}
}
class UserBoxADisplay extends ComponentBase<SomeProps, SomeState> {
...
protected _buildState(props: SomeProps, initialBuild: boolean, incomingState: {} | SomeState): TodoListState {
return {
boxA: UserStuffStore.getBoxAForUser(props.userCategory, props.username),
};
}
}We've added a new hook-like mechanism to ReSub in 2.3. It uses hooks under the covers, so treat them like hooks for ordering purposes and not using them inside conditional blocks. It basically uses the same autosubscribe logic from ComponentBase, but it does so from inside function components that have been wrapped in the withResubAutoSubscriptions HOC wrapper function. Let's just jump straight into an example, it'll be more clear. Let's adapt the initial example from earlier in the doc to the new format. The TodosStore is the same as above, but we can now make the TodoList function much simpler.
import * as React from 'react';
import { withResubAutoSubscriptions } from '@boatkit-io/resub';
import TodosStore = require('./TodosStore');
function TodoList() {
const todos = TodosStore.getTodos();
return (
<ul className="todos">
{ this.state.todos.map(todo => <li>{ todo }</li> ) }
</ul>
);
}
export default withResubAutoSubscriptions(TodoList);Much simpler, right? The call to getTodos is intercepted just like it were being called from a _buildState class method, and a subscription is automatically generated back to the TodosStore, which causes a useState mutation internally if the subscription gets triggered, which then makes the function component re-render again. Super easy. Just remember that these methods need to be treated like hooks for all intents and purposes when used like this, and that you need to wrap your function component in the withResubAutoSubscriptions call, or else autosubscriptions won't work and you'll be very confused. In development mode, there's a check that should warn you if you get this wrong, but in production mode (Options.development === false), the check disappears, so you're on your own!
To get the most out of ReSub, your components should inherit ComponentBase and should implement some or all of the methods below.
Returns true if the component is currently mounted, false otherwise. Subclasses should not override this method.
ReSub’s implementation of this method always returns true, which is inline with React's guidance. If you wish to apply optimizations using shouldComponentUpdate we provide a few different methods to do this:
- Provide a
shouldComponentUpdateComparatorto the ReSubOptionspayload. This is the default comparator that is used inshouldComponentUpdatefor components that extendComponentBase. This is a good way to apply customshouldComponentUpdatelogic to all your components. - Override
shouldComponentUpdatein specific components and don't call super - Apply a decorator to specific component classes to apply default or custom shouldComponentUpdate comparators. Examples:
@CustomEqualityShouldComponentUpdate(myComparatorFunction)- This will call your custom comparator function (for Props, State and Context), returningtruefromshouldComponentUpdateif your comparator returns false. This is built into ReSub's public module export.@DeepEqualityShouldComponentUpdate- This will do a deep equality check (_.isEqual) on Props, State & Context and returntruefromshouldComponentUpdateif any of the values have changed. As of 2.1.0+ of ReSub, this is no longer included in the public module export, to avoid including lodash in your bundles. The implementation, if you want to still use it:
import isEqual from 'lodash/isEqual';
import ComponentBase from './ComponentBase';
export function DeepEqualityShouldComponentUpdate<T extends { new(props: any): ComponentBase<any, any> }>(
constructor: T, context: ClassDecoratorContext<T>): T {
return CustomEqualityShouldComponentUpdate<any, any>(deepEqualityComparator)(constructor, context);
}
function deepEqualityComparator<P extends React.Props<any>, S = {}>(
this: ComponentBase<P, S>, nextProps: Readonly<P>, nextState: Readonly<S>, nextContext: any): boolean {
return isEqual(this.state, nextState) || isEqual(this.props, nextProps) || isEqual(this.context, nextContext);
}Note: _.isEqual is a deep comparison operator, and hence can cause performance issues with deep data structures. Weigh the pros/cons of using this deep equality comparator carefully.
Subclasses should implement some or all of the following methods:
protected _buildState(props: P, initialBuild: boolean, incomingState: {} | S): Partial<S> | undefined
This method is called to rebuild the module’s state. All but the simplest of components should implement this method. It is called on three occurrences:
- During initial component construction,
initialBuildwill be true. This is where you should set all initial state for your component. This case rarely needs special treatment because the component always rebuilds all of its state from its props, whether it's an initial build or a new props received event. - In the React lifecycle, during a
getDerivedStateFromProps, this is called before every render. - When this component subscribes to any stores, this will be called whenever the subscription is triggered. This is the most common usage of subscriptions, and the usage created by autosubscriptions.
Any calls from this method to store methods decorated with @autoSubscribe will establish an autosubscription.
React’s setState method should not be called directly in this function. Instead, the new state should be returned and ComponentBase will handle the update of the state.
Note: If your _buildState ever relies on component state, utilize the incomingState argument, otherwise you risk using an old snapshot of the state (See microsoft#131 for more details)
This method is automatically called from componentDidMount and componentDidUpdate, as both of these methods typically do the same work.
Methods include:
constructor(props: P)componentDidMount()componentWillUnmount()componentDidUpdate(prevProps: P, prevState: S)static getDerivedStateFromProps(prevProps: P, prevState: S)
Many of these methods are unnecessary in simple components thanks to _componentDidRender and _buildState, but may be overridden if needed. Implementations in subclasses must call super.
If using getDerivedState in a Subclass, Implementations must return ComponentBase.getDerivedStateFromProps(props, state), but can add additional changes to the return value.
ReSub’s true power is realized when creating subclasses of StoreBase. Several features are exposed as public methods on StoreBase, and subclasses should also implement some or all of the virtual methods below.
In addition to providing useful patterns for store creation, StoreBase also provides features to squeeze out additional performance through heavy data traffic.
By default, a store will instantly (and synchronously) notify all of its subscriptions when trigger is called. For stores that have heavy data traffic, this may cause components to re-render far more often than needed.
To solve this issue, stores may specify a throttle time limit by specifying throttleMs = X (X being a number of milliseconds) during construction. Any triggers within the time limit will be collected, de-duped, and callbacks will be called after the time is elapsed.
In applications with heavy data traffic, especially on mobile browsers, frequent component re-rendering can cause major performance bottlenecks. Trigger throttling (see above) helps this problem, but sometimes this isn’t enough. For example, if the developer wants to show an animation at full 60-fps, it is important that there is little to no other work happening at the same time.
StoreBase allows developers to block all subscription triggers on all stores until the block is lifted. All calls to trigger in this time will be queued and will be released once the block is lifted.
Because certain stores may be critical to the app, StoreBase allows stores to opt out of (and completely ignore) trigger blocks by passing bypassTriggerBlocks = true to the constructor.
Multiple stores or components might want to block triggers simultaneously, but for different durations, so StoreBase counts the number of blocks in effect and only releases triggers once the block count reaches 0.
Manually subscribe to this store. By default, the callback method will be called when the store calls trigger with any key, but this can be reduced by passing a specific key. For more information, see the “Subscriptions and Triggering” section.
subscribe returns a token that can be passed to unsubscribe.
Removes a subscription from the store.
Trigger all subscriptions that match the provided keyOrKeys to be called back. If no key is specified, StoreBase.Key_All will be used and all subscriptions will be triggered. For more information, see the “Subscriptions and Triggering” section.
This method returns a de-duped list of all keys on which subscribers have subscribed.
Returns true if a subscription has been made on the specified key, or false otherwise. This also responds to the Key_All key to check for global subscriptions.
Calling StoreBase.pushTriggerBlock() will halt all triggers on all stores until the trigger block is lifted by a subsequent call to StoreBase.popTriggerBlock(). For more information, see the “Trigger blocks” section.
Lifts the trigger block on all stores and releases any queued triggers. If more than one trigger block is in effect (because more than one store or component wants to block triggers simultaneously), popTriggerBlock will decrement the block count but not release the triggers. For more information, see the “Trigger blocks” section.
Subclass constructors should call super. throttleMs refers to the throttle time (see “Trigger throttling” section). bypassTriggerBlocks refers to the trigger blocking system (see “Trigger blocks” section).
StoreBase uses reference counting on subscriptions. This method is called whenever a subscription is first created, either as a global subscription (key = undefined in this function) or with a key.
Subclasses do not need to call super.
StoreBase uses reference counting on subscriptions. This method is called whenever a subscription is last removed, either as a global subscription (key = undefined in this function) or with a key.
Subclasses do not need to call super.
A small ecosystem is starting up around ReSub, so if you bring up an extension based on it, let us know and we'll add it to a list here:
- ReSub-entity: This project eases the use of ReSub by providing a simple function to create a Store for an entity
ReSub avoids taking a strong opinion on data flow in your project.
While it’s not encouraged, it’s fine for components to make calls to modify store data, for components and stores to make AJAX and other asynchronous calls, and for stores to subscribe to one another. Action creators may be used to organize data flow, but they’re not required and often not necessary.
Whether using ReSub or not, your app will likely scale best if it follows these guidelines:
- Components should remain pure, and as such, should only get data from props and stores.
- Store data should never be modified on the same cycle as component data fetching and rendering. Race conditions and update cycles can form when a component modifies store data while building its state.
To assist with performance analysis of your store and component state-building/-triggering, there is a performance module built into ReSub that marks durations for buildState functions and store trigger callbacks. If you want to enable it, call the setPerformanceMarkingEnabled(true) function available on the root ReSub module export.
ReSub uses mise for local tool management and pnpm for package management. Run mise trust once for this repository, then mise install and pnpm install.
Useful local tasks:
mise run lintmise run testmise run buildmise -q run next-versionmise run release
The release task requires a clean git working tree and calculates the next semantic version from conventional commits with get-next-version. If there are no releasable commits since the latest tag, the task exits without tagging or publishing. Otherwise, it updates package.json and pnpm-lock.yaml, creates a release commit and tag, pushes them, publishes to npm, and creates a GitHub release with generated notes. Set PUSH=0 to skip pushing the release commit and tag. Set GITHUB_RELEASE=0 to skip creating the GitHub release.
ReSub uses the standard decorator model supported by TypeScript 5 and newer. Do not enable experimentalDecorators; that flag selects TypeScript's legacy decorator behavior. If your tsconfig.json has an explicit lib list, include "decorators" so the standard decorator context types are available.
Standard decorators do not support parameter decorators. Code that previously used getTodosForUser(@key username: string) should move @key onto the method and pass argument indexes, e.g. @key(0) or @key(0, 1).
Vite's default TypeScript pipeline does not transform standard decorators before running React transforms. ReSub exports a small Vite plugin that runs TypeScript's standard-decorator transform for decorated TypeScript files:
import { defineConfig } from 'vite'
import { standardDecorators } from '@boatkit-io/resub/vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [standardDecorators(), react()],
})Put standardDecorators() before React plugins. The plugin uses the typescript package from your Vite project, so make sure typescript is installed.
It is fine to use ReSub without TypeScript, but without access to TypeScript’s method decorators, stores and components cannot leverage autosubscriptions, and as such, lose a lot of their value.
At the very least, developers can still leverage the organizational patterns of ComponentBase and StoreBase, and any virtual functions that subclasses implement will still be called.
ReSub relies heavily on standard TypeScript decorators, which are not supported out of the box when transpiling TypeScript via Babel. If you choose to transpile your project with Babel, configure Babel's decorators plugin for the current standard decorators proposal:
plugins: [
["@babel/plugin-proposal-decorators", { "version": "2023-11" }]
],You'll also need to install @babel/plugin-proposal-decorators.
ReSub includes an ESLint flat-config plugin for common ReSub usage issues. It is published at @boatkit-io/resub/eslint/index.mjs.
import resub from '@boatkit-io/resub/eslint/index.mjs';
export default [
{
files: ['src/**/*.{ts,tsx}'],
plugins: {
resub,
},
rules: {
'resub/incorrect-state-access': 'error',
'resub/state-keypaths': 'error',
'resub/override-calls-super': [
'error',
'_buildInitialState',
'componentDidMount',
'componentDidUpdate',
'componentWillUnmount',
],
},
},
];resub/incorrect-state-access reports this.state access from UNSAFE_componentWillMount and from methods reachable through this.method() calls. Pass additional method names after the severity to check other entry points.
resub/state-keypaths requires keys passed to @autoSubscribeWithKey(...), this.trigger(...), and this.subscribe(..., key) to match field paths accessed through this._state in the same store class. It accepts string and number literals, enum members, static constants, arrays of keys, keyPath(...) descriptors containing keyArg(...) references, and formCompoundKey(...). Dynamic key expressions are reported because they cannot be checked. If your store uses a different state field name, configure it with { statePropertyName: 'state' }.
resub/override-calls-super requires configured override methods to call the matching super method in the top-level statements of the method body.