@akutruff/dimmer
TypeScript icon, indicating that this package has built-in type declarations

0.4.3 • Public • Published

Build Status Build Size Version Downloads

Dimmer

State-management designed to minimize renders while staying out of your way and letting you write natural mutable code without sacrificing object references.

npm install @akutruff/dimmer @akutruff/dimmer-react # core and react bindings

Full React demo here.

Features

  • [x] Object change recording
  • [x] Mutable coding style
  • [x] Cyclic references
  • [x] Multiple references to same object in state graph
  • [x] Map, Set, and Array support
  • [x] Async mutation with rendering control and state change control
  • [x] Object property change subscriptions
  • [x] Time travel and undo / redo built-in
  • [x] React 18 support with useSyncExternalStore
  • [x] React render batching
  • [ ] React Concurrent Mode (may work but needs testing)

Dimmer is designed to minimize component re-rendering as much as possible. In general, almost all your components should be wrapped in React.Memo and use dimmers hooks to determine when to trigger re-renders.

Create a store

import { createRecordingProxy } from '@akutruff/dimmer';
import { useRootState } from '@akutruff/dimmer-react';

export interface Person {
    id: number,
    name: string,
    age: number,

}

export interface RootState {
    selectedPerson: Person,
    people: Person[],
    counter: number,
}

export function useAppState() {
    return useRootState() as RootState;
}

export function createRootState() {
    const people = [
        { id: 0, name: 'Bob', age: 42 },
        { id: 1, name: 'Alice', age: 40 }
    ];

    return createRecordingProxy({
        selectedPerson: people[0],
        people,
        counter: 0
    });
}

createRecordingProxy returns a proxy wrapped object that now starts tracking changes as patches to your object.

Simple Counter App

const Incrementor: FC = React.memo(() => {
  const state = useAppState();
  
  const counter = useSnapshot(state, state => state.counter);
  const increment = useMutator(state, state => state.counter++);

  return (
    <div>
      <div>value: {counter}</div>
      <button type="button" onClick={increment}>Click me</button>
    </div>
  );
});

const App: FC = () => {  
  const [state] = useState(() => createRootState());

  const patchTrackerContextValue = usePatchTrackerContextValue({ state, dispatch: () => { } });

  return (
    <PatchTrackerContext.Provider value={patchTrackerContextValue}>
      <Incrementor />
    </PatchTrackerContext.Provider>
  );
};

Shows the typical pattern for subscribing to and mutating data while isolating the rendering behavior to just the affected data. Uses React.memo to ensure complete isolation.

The Recording Proxy

import createRecordingProxy from '@akutruff/dimmer'

//Make a proxy for state
const state = createRecordingProxy({
        bob: {
            name: "Bob",
            age: 42
        },
        alice: {
            name: "Alice",
            age: 40
        }
    });

// The object returned is another recording proxy.
const bob = state.bob;

createRecordingProxy() wraps an object in a proxy that does two things: keep track of property assignments and wrap child objects in proxies when they are accessed. This proxy is at the core of how dimmer works.

Unlike many other state libraries, you can have multiple references to the same object in your state tree and it all just works automatically. There's no more need for keeping a table of ids when you normally don't need it.

As a rule, you should always be using proxies when dealing with your objects. It's best to create a root state object and store all state there as is typical in state stores. That way, as you access objects in the hierarchy they are automatically setup as proxies for change recording. Note that you do not need a single state store. Change tracking will totally work fine with independent trees of objects.

Note that some functions like recordPatches() will automatically ensure that a recording proxy exists or is created before calling your code.

see caveats

useRootState()

Accesses the topmost state that you gave to the PatchTrackerContext.Provider via usePatchTrackerContextValue.

It returns an untyped value, so it is a good idea to have a simple wrapper function around the hook that casts to your root type.

useSnaphot() and Selectors

const [name, age] = useSnapshot(person, person => [person.name, person.age]);

This is the main way to accomplish reactivity against your state. The first parameter is the state you wish to observe, and the second parameter is a selector function.

In the above example, by accessing person.name and person.age in the selector, the component will rerender ONLY when the name or age properties change on the person object.

The selector function is special. When your component is mounted, this function is first used to record the properties you will be accessing from the subscribed state. This is done by passing an empty proxy object as the parameter to selector you provide. This means that you should not put any code that does any type of complex logic in the selector. After the initial selector is run with a proxy, the selector is used with your real state to extract the values from the state.

Also, there is an optional third parameter that allows you to pass a dependency list that will trigger a re-render and resubscribe to the object tree. This is helpful for when you have a value from your component params that you wish to use to access your data, such as an id in a Map or array index.

Deep values

const [name] = useSnapshot(state, state => [state.person.friend.name]);

If any property value in the chain changes the component will re-render. This includes if state.person is reassigned.

all()

const [person] = useSnapshot(state, state => [all(state.person)]);

Subscribes to all property changes on the person object only. Will re-render if any property on the person object changes.

const [value] = useSnapshot(state, state => [all(state.person).friend.name]);

Note that you can also make complicated chains. Here, any property change on state.person will trigger a re-render as well as the subproperty friend.name.

elements()

const CollectionComponent : FC = () => {
    const state = useAppState();
    const [people] = useSnapshot(state, state => [elements(state.people)]);
}

Subscribes to the collection as a whole and will re-render if an item is added or removed from the collection. This should work for arrays, maps, and sets. Maps and sets may currently have an issue that's being investigated.

Note: if a property inside the people array changes, it will NOT trigger a re-render of the component. This is by design.

Subscribe to an array index

const [name] = useSnapshot(state, state => [state.people[0].name]);

In the above example, the array index is respected.

const NameComponent : FC<{index: number}> = ({index}) => {
    const state = useAppState();
    const [name] = useSnapshot(state, state => [state.people[index].name], [index]);
}

The selector depends on an external parameter that is not on the state object. To make sure it resubscribes, pass in the parameters as a dependency like in typical react hooks

map_get()

const CollectionComponent : FC = () => {
    const state = useAppState();
    const [people] = useSnapshot(state, state => [map_get(state.myMap, "someKey")]);
}

To subscribe to changes in Map<> objects, you need to use the special map_get function to observe a particular key.

useProjectedSnapshot()

const PersonDetails : FC<{person: Person}> = React.memo(({person}) => {
    const state = useAppState();
    const isSelected = useProjectedSnapshot(state, state => state.selectedPerson, state => areSame(state.selectedPerson, person), [person]);
    return (
      {isSelected ? 'selected' : 'nope'}  
    );
});

Same usage as useSnapshot but allows you to add an additional projection function. This function can arbitrary or complex values from your state, which is only executed when the object properties in your selector function change. The result of the projection is memoized, so even if the properties in your selector function change, if the result of your projection equals the previous result, your component won't re-render!

In the example code, you could have 100 PersonDetails components on-screen, but only the previously selected component, and the newly selected component will re-render. This is a great way to optimize the display of collections.

useMutator()

const state = useAppState();

const counter = useSnapshot(state, state => state.counter);
const increment = useMutator(state, state => state.counter++);

return (
    <div>
        <div>value: {counter}</div>
        <button type="button" onClick={increment}>Click me</button>
    </div>
);

Allows you to do mutations on your state and record any changes that happen. No mutations should be done outside the passed in mutator function and if you need to use additional values from your component props, you should add the prop to the optional dependency list as the third argument to useMutator.

useMutatorAsync()

const state = useAppState();

const [isLoading, value] = useSnapshot(state, state => [state.isLoading, state.value]);

const loadWords = useMutatorAsync(state, async function* asyncFunction(state) {
    state.isLoading = true;
    const fetchedValue = await fetchSomething();
    yield; // starts the change recording process after any awaits 

    state.isLoading = false;
    state.value = fetchedValue;
});

useEffect(() => loadWords(), []);

return (
    <div>
        <div>isLoading: {isLoading}</div>
        <div>value: {value}</div>        
    </div>
);

To support asynchronous loading and to control change-recording and as well as re-rendering, you use an async generator function with a passed in state. At the begining of your mutator, you may modify your state and the rendering will not occur until you either await something or your function exits.

When you await inside your function, change recording stops at that moment. When the await returns you may do additional await calls, but you must call yield prior to modifying your state. Calling yield will begin change recording, and will again allow for re-rendering.

This may seem a bit cumbersome, but it allows you to completely control how a series of multiple async awaits can occur without affecting state until all is completed or successful

Again, there is an optional dependency list parameter as well in order to use props inside your mutator function.

Warning: The following applies to any asynchronous code, not just Dimmer. Be careful when using async functions with mutation! You could have multiple async functions executing, or simply some synchronous function modify state while your async process under way! It is much better to make an object that represents your async call with local state on it that is unique per async operation. That way if it gets replaced or invalidated you can ignore everything but the latest operation.

Nested Async Generators

const state = useAppState();

const [isLoading, value] = useSnapshot(state, state => [state.isLoading, state.value]);

async function* myFetcher(state: State) {
    
    const fetchedValue = await fetchSomething();
    yield; // must yield because we awaited.

    state.prop0 = fetchedValue;
}

const loadWords = useMutatorAsync(state, async function* asyncFunction(state) {
    state.isLoading = true;
    
    yield* myFetcher(state);    
    yield; //caveat: You must yield in the caller after the nested function returns

    state.isLoading = false;    
});

useEffect(() => loadWords(), []);

Change recording with patches

Dimmer tracks which properties change as your code executes. These changes are stored in patches just like a git commit.

import recordPatches from '@akutruff/dimmer'

const state = { counter: 0, otherCounter: 0 };
const patches = recordPatches(state => state.counter += 1, state);
// patches equals: [{"counter" => 0}]

Runs the increment function and records any object mutation into a list of Map<> objects containing the original value of each property.

These patches allows you to rewind and undo changes by calling applyPatch().

Deep Object Hierarchies

const bob = { favoriteFood: 'tacos' };
const alice = { favoriteFood: 'cake',  homie: bob };
const fred = { favoriteFood: 'pizza', homie: bob };

const state = { bob, alice, fred };

const patches = recordPatches({alice, fred} => {
    alice.homie = fred;
    alice.homie.favoriteFood = 'nachos'; // Will now modify fred as you would expect.
    }, state);

// patches equals: [{"homie" => bob}, {"favoriteFood" => 'pizza'}]
getPatchTarget(patches[0]) // will equal alice
getPatchTarget(patches[1]) // will equal fred

Runs the increment function and records any object mutation into a list of Patch objects that store which properties have changed and the original value of each property. These patches allows you to rewind and undo changes by calling applyPatch().

Reverse a patch to go the opposite direction in history

const reversePatch = createReversePatch(patches[0]);
// reverse patch: {"counter" => 1}

Undo / Redo:

const state = { counter: 0 };
const [ backwardsPatch ] = recordPatches(state => state.counter += 1, state);
//state equals {counter: 1}

const forwardPatch = createReversePatch(backwardsPatch);

//undo
applyPatch(backwardsPatch);
//state equals {counter: 0}

//redo
applyPatch(forwardPatch);
//state equals {counter: 1}

Object proxy Utilities

isProxy(obj: any): boolean

Returns whether obj is a reference to a recording proxy or not.

tryGetProxy<T>(obj: T): T | undefined

Returns the current recording proxy for obj if one exists. If obj is a reference to a proxy, then obj is returned.

ensureProxy<T extends object>(obj: T) : T

If no recording proxy for obj exists, then one is created and returned. If obj is a reference to a proxy, then obj is returned.

asOriginal<T>(obj: T): T

If the obj parameter is a recording proxy, the underlying object being recorded is returned. If obj is not a proxy, then obj is returned.

areSame(one: any, two: any): boolean

Returns if one and two are the same object. If either object is a proxy, the underlying object is used for the comparison.

Equivalent to asOriginal(myObject) === asOriginal(otherObject)

getPatchTarget<T>(patch: InferPatchType<T>): T | undefined

Returns the object from which the patch was calculated.

doNotTrack<T>(obj: T): T

Disables proxy generation and change tracking for an object. This is useful for when you store references to 3rd party instances in your state tree that don't behave well when proxied.

Untracked objects in your state tree will not return proxies from their child properties.

Untracked objects will behave differently with some utility functions which will treat the object as its own "proxy."

  • tryGetProxy - will return the untracked object.
  • ensureProxy - will return the untracked object. No proxy will be generatoed will continue to work with objects that are untracked. They will simply

isProxy will return false for untracked objects.

Caveats

Object Reference Comparison

Be weary of object reference comparisons as you may be trying to compare an original object with its proxy or vice versa.

myObject === otherObject //either object could be a proxy!

areSame(myObject, otherObject)

asOriginal(myObject) === asOriginal(otherObject)

ensureProxy(myObject) === ensureProxy(otherObject)

Package Sidebar

Install

npm i @akutruff/dimmer

Weekly Downloads

2

Version

0.4.3

License

MIT

Unpacked Size

166 kB

Total Files

63

Last publish

Collaborators

  • akutruff