Managing React State with Proxily
React is beautiful in it’s simplicity. You can take a complex UI and break it up into very small components, each of which, is simple and easy to understand. To managing state across these components you need a state-management library that facilitates the rendering of components when data they reference changes. Most libraries do that by imposing their own highly prescriptive structure on how you organize your code and data.
Fortunately, Javascript itself already has a standard feature for organizing code and data that is also perfect for this need — the class. Proxily is a new state management library that wraps class instances in a proxy, tracks references to properties and handles the re-rendering of components that reference them when values change.
Define your state using standard Javascript and make it observable:
import {observable} from 'proxily';class Counter {
count = 0;
increment () {
this.count++;
}
}export const state = observable(new Counter()); // Make it observable
Make your consuming components observers:
import {observer} from 'proxily';
import {state} from 'counter';function App() {
const {count, increment} = state; // Use your state
return (
<div>
<span>Count: {count}</span>
<button onClick={increment}>Increment</button>
</div>
);
};export default observer(App); // Make your component an observer
While you can actually use any Javascript object in place of class-based objects, classes provide key features for React, such as automatically saving and restoring complex state. Classes also provide a standardized way for implementing actions and selectors which are popular patterns for React.
Actions
Actions are simply class methods. Proxily makes methods more palatable by allowing them be used as normal functions. In the Counter class example we had an increment method which we destructured from state:
const {count, increment} = state; // Destructure increment works!
Then we used increment in the onClick event directly.
<button onClick={increment}>Increment</button>
Proxily binds methods to their object so you can use objects methods without always having to reference them as object.method. This makes it easy to use normal class methods as actions.
Selectors
You can reference data properties directly in components. For derived state you can use the selector pattern with the standard Javascript getter:
import {observable, observer} from 'proxily';class NameList {
names = [];
get sortedNames() { // selector
this.names.slice(0).sort();
}
}
const nameList = observable(new NameList());
You can memoize the results of getters such that the previous result is returned so long as dependent values don’t change:
import {cache} from 'proxily';memoize(NameList, p => p.sortedNames);
Getters can also be destructured and used directly:
function List () {
const {sortedNames} = nameList;
return (<> {sortedNames.map(n => <Name name={n} />} </>)
}export default observer(List);
As we have seen thus far it is possible to organize your code and data using standard Javascript features with minimal state management “glue”. You are now free to organize data and logic in any way that suits your application.
Everything in the Box
Proxily offers a robust set of features needed for creating real-world React applications:
- Automatic persistence of complex state
- Integration with Redux-devtools and Redux-sagas
- State branching with undo, redo, commit and rollback
- Immutable snapshots
- Support for suspense, transitions and deferred values in React 18+
Persistence & Serialization
Classes have traditionally been difficult to serialize. Proxily makes it easy. Nominate your classes as serializable:
serializable({Counter});
To make your state persistent simply replace
const state = observable(new Counter());
with
const state = persist(new Counter());
Now your state will be saved and retrieved from local storage even with references to other classes, including circular references. While you can’t serialize internal objects like DOM references, most common data objects like Arrays, Sets, Maps, Dates, strings and numbers are fair game. Needless to say your classes must have constructors without required arguments.
When You Need Immutable Data
As you may have gathered Proxily does not require that your data is immutable. It triggers re-renders from normal updates. If you need immutable data, Proxily has useAsImmutable which provides an immutable snapshot. This is useful when immutable dependencies are expected.
Suppose we have a news object with a list of topics and resulting stories. The stores must be fetched when the topics change:
const news = observable({
topics: ["politics", "tech", "cooking"],
stories: {}
});
We fetch updated stories when any of the topics change with useEffect. useEffect expects an immutable dependency so we simply wrap our news.topics in useAsImmutable:
import {useAsImmutable} from 'proxily';function Stories {
useEffect( () => { // Fire query when topics change
axios.get('/getStories?topics=' + news.topics.join(','))
.then((r) => news.stories = r.toJSON());
}, [useAsImmutable(news.topics)]); // Immutable snapshot
// Render news.stories
}export default observable(MyComponent);
Any time one of the topics changes useAsImmutable will return a new copy of the topics and trigger the effect to fetch the stories.
Debugging
Redux-devtools lets you peer into your state and see how actions change it. You can travel backwards or forwards in time restoring the state as it was at the completion of the action. With Proxily each top level method is considered an action so redux-devtools can be used just as it would be used with Redux.
Testing
Classes are very easy to test . You can test the Counter class like this:
const counter = new Counter();
counter.increment();
expect(counter.value).toBe(1);
React components require a bit more effort. Passing your state to components as properties or in a context provider makes it easier to do. Here is a MyCounter component that expects a Counter to be passed in as a property:
function MyCounter({counter} : {counter : Counter}) {
const {value, increment} = counter;
return (
<div>
<span>Count: {value}</span>
<button onClick={increment}>Increment</button>
</div>
);
});
export default observer(MyCounter);
To test the MyCounter component, create a Jest mock of the Counter class and pass that to the component. Proxily provides the jestMockFromClass helper which creates a standard jest Mock with an object instantiated from the class that is populated with test values:
const mockState = jestMockFromClass(Counter, {value: 5});
render(<MyCounter counter={mockState} />);
Now you can use standard Jest features to test your component:
screen.getByText('Increment').click();
expect(mockState.increment).toBeCalled();
How Proxily Works
Proxily uses an ES6 Proxy to detect changes to objects wrapped in observable. Once an object is wrapped all referenced objects are automatically wrapped such that you only need to wrap your root object in observable. The proxy also detects which properties are referenced within an observer, which you use to wrap your components. Now Proxily can re-render the referencing components when dependent properties change.
Other libraries such as the very popular MobX and Immer (used by Redux devtools) have used Proxies and their predecessors — getters and setters for years. What sets Proxily apart is two key philosophies:
- Make use of common Javascript language features without the need to learn a proprietary usage pattern.
- Provide the depth of features that users of mature immutable libraries like Redux have come to expect.
That’s it for an intro to Proxily. You can check out the documentation or go right to the traditional Todo List application to see a full fledged example.
I have been writing libraries for decades and I also use the libraries I write in real applications. I can honestly say that for me Proxily matches well with the simplicity and elegance of React, makes development a pleasure again. I hope you will consider Proxily and find it useful for your next project.
Next Up
The next few articles on Proxily will cover in depth:
- Concurrent rendering with React 18
- Transactions which provide undo/redo and commit/rollback
- Asynchronous logic with Redux-sagas in proxily