@isograph/react-disposable-state 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useCachedPrecommitValue = void 0;
4
+ const react_1 = require("react");
5
+ const useHasCommittedRef_1 = require("./useHasCommittedRef");
6
+ /**
7
+ * usePrecommitValue<T>
8
+ * - Takes a mutable parent cache, a factory function, and an onCommit callback.
9
+ * - Returns T before the initial commit, and null afterward.
10
+ * - Calls onCommit with the ItemCleanupPair during the first commit.
11
+ * - The T from the render phase is only temporarily retained. It may have been
12
+ * disposed by the time of the commit. If so, this hook checks the parent cache
13
+ * for another T or creates one, and passes this T to onCommit.
14
+ * - If the T returned during the last render is not the same as the one that
15
+ * is passed to onCommit, during the commit phase, will schedule another render.
16
+ *
17
+ * Invariant: the returned T has not been disposed during the tick of the render.
18
+ * The T passed to the onCommit callback has not been disposed when the onCommit
19
+ * callback is called.
20
+ *
21
+ * Passing a different parentCache:
22
+ * - Pre-commit, passing a different parentCache has the effect of "resetting" this
23
+ * hook's state to the new cache's state. For example, if you have a cache associated
24
+ * with a set of variables (e.g. {name: "Matthew"}), and pass in another cache
25
+ * (e.g. associated with {name: "James"}), which is empty, the hook will fill that
26
+ * new cache with the factory function.
27
+ *
28
+ * Passing a different factory:
29
+ * - Passing a different factory has no effect, except when factory is called,
30
+ * which is when the parent cache is being filled, or during the initial commit.
31
+ *
32
+ * Passing a different onCommit:
33
+ * - Passing a different onCommit has no effect, except for during the initial commit.
34
+ *
35
+ * Post-commit, all parameters are ignored and the hook returns null.
36
+ */
37
+ function useCachedPrecommitValue(parentCache, onCommit) {
38
+ // TODO: there should be two APIs. One in which we always re-render if the
39
+ // committed item was not returned during the last render, and one in which
40
+ // we do not. The latter is useful for cases where every disposable item
41
+ // behaves identically, but must be loaded.
42
+ //
43
+ // This hook is the former, i.e. re-renders if the committed item has changed.
44
+ const [, rerender] = (0, react_1.useState)(null);
45
+ (0, react_1.useEffect)(() => {
46
+ // On first commit, cacheItem may be disposed, because during the render phase,
47
+ // we only temporarily retained the item, and the temporary retain could have
48
+ // expired by the time of the commit.
49
+ //
50
+ // So, we can be in one of two states:
51
+ // - the item is not disposed. In that case, permanently retain and use that item.
52
+ // - the item is disposed. In that case, we can be in two states:
53
+ // - the parent cache is not empty (due to another component rendering, or
54
+ // another render of the same component.) In that case, permanently retain and
55
+ // use the item from the parent cache. (Note: any item present in the parent
56
+ // cache is not disposed.)
57
+ // - the parent cache is empty. In that case, call factory, getting a new item
58
+ // and a cleanup function.
59
+ //
60
+ // After the above, we have a non-disposed item and a cleanup function, which we
61
+ // can pass to onCommit.
62
+ const undisposedPair = cacheItem.permanentRetainIfNotDisposed(disposeOfTemporaryRetain);
63
+ if (undisposedPair !== null) {
64
+ onCommit(undisposedPair);
65
+ }
66
+ else {
67
+ // The cache item we created during render has been disposed. Check if the parent
68
+ // cache is populated.
69
+ const existingCacheItemCleanupPair = parentCache.getAndPermanentRetainIfPresent();
70
+ if (existingCacheItemCleanupPair !== null) {
71
+ onCommit(existingCacheItemCleanupPair);
72
+ }
73
+ else {
74
+ // We did not find an item in the parent cache, create a new one.
75
+ onCommit(parentCache.factory());
76
+ }
77
+ // TODO: Consider whether we always want to rerender if the committed item
78
+ // was not returned during the last render, or whether some callers will
79
+ // prefer opting out of this behavior (e.g. if every disposable item behaves
80
+ // identically, but must be loaded.)
81
+ rerender({});
82
+ }
83
+ }, []);
84
+ const hasCommittedRef = (0, useHasCommittedRef_1.useHasCommittedRef)();
85
+ if (hasCommittedRef.current) {
86
+ return null;
87
+ }
88
+ // Safety: item is only safe to use (i.e. guaranteed not to have disposed)
89
+ // during this tick.
90
+ const [cacheItem, item, disposeOfTemporaryRetain] = parentCache.getOrPopulateAndTemporaryRetain();
91
+ return { state: item };
92
+ }
93
+ exports.useCachedPrecommitValue = useCachedPrecommitValue;
@@ -0,0 +1,9 @@
1
+ import { ParentCache } from "./ParentCache";
2
+ import { ItemCleanupPair } from "@isograph/disposable-types";
3
+ import { UnassignedState } from "./useUpdatableDisposableState";
4
+ type UseUpdatableDisposableStateReturnValue<T> = {
5
+ state: T;
6
+ setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;
7
+ };
8
+ export declare function useDisposableState<T = never>(parentCache: ParentCache<T>): UseUpdatableDisposableStateReturnValue<T>;
9
+ export {};
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useDisposableState = void 0;
4
+ const react_1 = require("react");
5
+ const useCachedPrecommitValue_1 = require("./useCachedPrecommitValue");
6
+ const useUpdatableDisposableState_1 = require("./useUpdatableDisposableState");
7
+ function useDisposableState(parentCache) {
8
+ var _a, _b, _c;
9
+ const itemCleanupPairRef = (0, react_1.useRef)(null);
10
+ const preCommitItem = (0, useCachedPrecommitValue_1.useCachedPrecommitValue)(parentCache, (pair) => {
11
+ itemCleanupPairRef.current = pair;
12
+ });
13
+ const { state: stateFromDisposableStateHook, setState } = (0, useUpdatableDisposableState_1.useUpdatableDisposableState)();
14
+ (0, react_1.useEffect)(function cleanupItemCleanupPairRefAfterSetState() {
15
+ if (stateFromDisposableStateHook !== useUpdatableDisposableState_1.UNASSIGNED_STATE) {
16
+ if (itemCleanupPairRef.current !== null) {
17
+ itemCleanupPairRef.current[1]();
18
+ itemCleanupPairRef.current = null;
19
+ }
20
+ else {
21
+ throw new Error("itemCleanupPairRef.current is unexpectedly null. " +
22
+ "This indicates a bug in react-disposable-state.");
23
+ }
24
+ }
25
+ }, [stateFromDisposableStateHook]);
26
+ (0, react_1.useEffect)(function cleanupItemCleanupPairRefIfSetStateNotCalled() {
27
+ return () => {
28
+ if (itemCleanupPairRef.current !== null) {
29
+ itemCleanupPairRef.current[1]();
30
+ itemCleanupPairRef.current = null;
31
+ }
32
+ };
33
+ }, []);
34
+ // Safety: we can be in one of three states. Pre-commit, in which case
35
+ // preCommitItem is assigned, post-commit but before setState has been
36
+ // called, in which case itemCleanupPairRef.current is assigned, or
37
+ // after setState has been called, in which case
38
+ // stateFromDisposableStateHook is assigned.
39
+ //
40
+ // Therefore, the type of state is T, not T | undefined. But the fact
41
+ // that we are in one of the three states is not reflected in the types.
42
+ // So we have to cast to T.
43
+ //
44
+ // Note that in the post-commit post-setState state, itemCleanupPairRef
45
+ // can still be assigned, during the render before the
46
+ // cleanupItemCleanupPairRefAfterSetState effect is called.
47
+ const state = (_c = (_a = (stateFromDisposableStateHook != useUpdatableDisposableState_1.UNASSIGNED_STATE
48
+ ? stateFromDisposableStateHook
49
+ : null)) !== null && _a !== void 0 ? _a : (_b = itemCleanupPairRef.current) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : preCommitItem === null || preCommitItem === void 0 ? void 0 : preCommitItem.state;
50
+ return {
51
+ state: state,
52
+ setState,
53
+ };
54
+ }
55
+ exports.useDisposableState = useDisposableState;
56
+ // @ts-ignore
57
+ function tsTests() {
58
+ let x;
59
+ const a = useDisposableState(x);
60
+ // @ts-expect-error
61
+ a.setState(["asdf", () => { }]);
62
+ // @ts-expect-error
63
+ a.setState([useUpdatableDisposableState_1.UNASSIGNED_STATE, () => { }]);
64
+ const b = useDisposableState(x);
65
+ // @ts-expect-error
66
+ b.setState([useUpdatableDisposableState_1.UNASSIGNED_STATE, () => { }]);
67
+ b.setState(["asdf", () => { }]);
68
+ }
@@ -0,0 +1,5 @@
1
+ import { MutableRefObject } from "react";
2
+ /**
3
+ * Returns true if the component has committed, false otherwise.
4
+ */
5
+ export declare function useHasCommittedRef(): MutableRefObject<boolean>;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useHasCommittedRef = void 0;
4
+ const react_1 = require("react");
5
+ /**
6
+ * Returns true if the component has committed, false otherwise.
7
+ */
8
+ function useHasCommittedRef() {
9
+ const hasCommittedRef = (0, react_1.useRef)(false);
10
+ (0, react_1.useEffect)(() => {
11
+ hasCommittedRef.current = true;
12
+ }, []);
13
+ return hasCommittedRef;
14
+ }
15
+ exports.useHasCommittedRef = useHasCommittedRef;
@@ -0,0 +1,13 @@
1
+ import { ParentCache } from "./ParentCache";
2
+ /**
3
+ * useLazyDisposableState<T>
4
+ * - Takes a mutable parent cache and a factory function
5
+ * - Returns { state: T }
6
+ *
7
+ * This lazily loads the disposable item using useCachedPrecommitValue, then
8
+ * (on commit) sets it in state. The item continues to be returned after
9
+ * commit and is disposed when the hook unmounts.
10
+ */
11
+ export declare function useLazyDisposableState<T>(parentCache: ParentCache<T>): {
12
+ state: T;
13
+ };
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useLazyDisposableState = void 0;
4
+ const react_1 = require("react");
5
+ const useCachedPrecommitValue_1 = require("./useCachedPrecommitValue");
6
+ /**
7
+ * useLazyDisposableState<T>
8
+ * - Takes a mutable parent cache and a factory function
9
+ * - Returns { state: T }
10
+ *
11
+ * This lazily loads the disposable item using useCachedPrecommitValue, then
12
+ * (on commit) sets it in state. The item continues to be returned after
13
+ * commit and is disposed when the hook unmounts.
14
+ */
15
+ function useLazyDisposableState(parentCache) {
16
+ var _a, _b;
17
+ const itemCleanupPairRef = (0, react_1.useRef)(null);
18
+ const preCommitItem = (0, useCachedPrecommitValue_1.useCachedPrecommitValue)(parentCache, (pair) => {
19
+ itemCleanupPairRef.current = pair;
20
+ });
21
+ (0, react_1.useEffect)(() => {
22
+ var _a;
23
+ const cleanupFn = (_a = itemCleanupPairRef.current) === null || _a === void 0 ? void 0 : _a[1];
24
+ // TODO confirm useEffect is called in order.
25
+ if (cleanupFn == null) {
26
+ throw new Error("cleanupFn unexpectedly null. This indicates a bug in react-disposable-state.");
27
+ }
28
+ return cleanupFn;
29
+ }, []);
30
+ const returnedItem = (_a = preCommitItem === null || preCommitItem === void 0 ? void 0 : preCommitItem.state) !== null && _a !== void 0 ? _a : (_b = itemCleanupPairRef.current) === null || _b === void 0 ? void 0 : _b[0];
31
+ if (returnedItem != null) {
32
+ return { state: returnedItem };
33
+ }
34
+ // Safety: This can't happen. For renders before the initial commit, preCommitItem
35
+ // is non-null. During the initial commit, we assign itemCleanupPairRef.current,
36
+ // so during subsequent renders, itemCleanupPairRef.current is non-null.
37
+ throw new Error("returnedItem was unexpectedly null. This indicates a bug in react-disposable-state.");
38
+ }
39
+ exports.useLazyDisposableState = useLazyDisposableState;
@@ -0,0 +1,39 @@
1
+ import { ItemCleanupPair } from "@isograph/disposable-types";
2
+ export declare const UNASSIGNED_STATE: unique symbol;
3
+ export type UnassignedState = typeof UNASSIGNED_STATE;
4
+ type UseUpdatableDisposableStateReturnValue<T> = {
5
+ state: T | UnassignedState;
6
+ setState: (pair: ItemCleanupPair<Exclude<T, UnassignedState>>) => void;
7
+ };
8
+ /**
9
+ * useUpdatableDisposableState
10
+ * - Returns a { state, setItem } object.
11
+ * - setItem accepts an ItemCleanupPair<T>, and throws if called before commit.
12
+ * - setItem sets the T in state and adds it to a set.
13
+ * - React's behavior is that when the hook commits, whatever item is currently
14
+ * returned from the useState hook is the oldest item which will ever be returned
15
+ * from that useState hook. (More newly created ones can later be returned with
16
+ * concurrent mode.)
17
+ * - When this hook commits, all items up to, but not including, the item currently
18
+ * returned from the useState hook are disposed and removed from the set.
19
+ *
20
+ * Calling setState before the hook commits:
21
+ * - Calling setState before the hook commits is disallowed because until the hook
22
+ * commits, React will not schedule any unmount callbacks, meaning that if this
23
+ * hook never commits, any disposable items passed to setState will never be
24
+ * disposed.
25
+ * - We also cannot store them in some cache, because multiple components can share
26
+ * the same cache location (for example, if they are loading the same query from
27
+ * multiple components), so updating the cache with the disposable item will cause
28
+ * both components to show the updated data, which is almost certainly a bug.
29
+ * - Note that calling setState before commit is probably an anti-pattern! Consider
30
+ * not doing it.
31
+ * - If you must, the workaround is to lazily load the disposable item with
32
+ * useDisposableState or useLazyDisposableState, and update the cache location
33
+ * instead of calling setState before commit. One can update the cache location
34
+ * by calling setState on some parent component that has already mounted, and
35
+ * therefore passing in different props.
36
+ * - This may only work in concurrent mode, though.
37
+ */
38
+ export declare function useUpdatableDisposableState<T = never>(): UseUpdatableDisposableStateReturnValue<T>;
39
+ export {};
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useUpdatableDisposableState = exports.UNASSIGNED_STATE = void 0;
4
+ const react_1 = require("react");
5
+ const useHasCommittedRef_1 = require("./useHasCommittedRef");
6
+ exports.UNASSIGNED_STATE = Symbol();
7
+ /**
8
+ * useUpdatableDisposableState
9
+ * - Returns a { state, setItem } object.
10
+ * - setItem accepts an ItemCleanupPair<T>, and throws if called before commit.
11
+ * - setItem sets the T in state and adds it to a set.
12
+ * - React's behavior is that when the hook commits, whatever item is currently
13
+ * returned from the useState hook is the oldest item which will ever be returned
14
+ * from that useState hook. (More newly created ones can later be returned with
15
+ * concurrent mode.)
16
+ * - When this hook commits, all items up to, but not including, the item currently
17
+ * returned from the useState hook are disposed and removed from the set.
18
+ *
19
+ * Calling setState before the hook commits:
20
+ * - Calling setState before the hook commits is disallowed because until the hook
21
+ * commits, React will not schedule any unmount callbacks, meaning that if this
22
+ * hook never commits, any disposable items passed to setState will never be
23
+ * disposed.
24
+ * - We also cannot store them in some cache, because multiple components can share
25
+ * the same cache location (for example, if they are loading the same query from
26
+ * multiple components), so updating the cache with the disposable item will cause
27
+ * both components to show the updated data, which is almost certainly a bug.
28
+ * - Note that calling setState before commit is probably an anti-pattern! Consider
29
+ * not doing it.
30
+ * - If you must, the workaround is to lazily load the disposable item with
31
+ * useDisposableState or useLazyDisposableState, and update the cache location
32
+ * instead of calling setState before commit. One can update the cache location
33
+ * by calling setState on some parent component that has already mounted, and
34
+ * therefore passing in different props.
35
+ * - This may only work in concurrent mode, though.
36
+ */
37
+ function useUpdatableDisposableState() {
38
+ const hasCommittedRef = (0, useHasCommittedRef_1.useHasCommittedRef)();
39
+ const undisposedICIs = (0, react_1.useRef)(new Set());
40
+ const setStateCountRef = (0, react_1.useRef)(0);
41
+ const [stateICI, setStateICI] = (0, react_1.useState)(exports.UNASSIGNED_STATE);
42
+ const setStateAfterCommit = (0, react_1.useCallback)((itemCleanupPair) => {
43
+ if (!hasCommittedRef.current) {
44
+ throw new Error("Calling setState before the component has committed is unsafe and disallowed.");
45
+ }
46
+ const ici = {
47
+ item: itemCleanupPair[0],
48
+ cleanup: itemCleanupPair[1],
49
+ index: setStateCountRef.current,
50
+ };
51
+ setStateCountRef.current++;
52
+ undisposedICIs.current.add(ici);
53
+ setStateICI(ici);
54
+ }, [setStateICI]);
55
+ (0, react_1.useEffect)(function cleanupUnreachableItems() {
56
+ const indexInState = stateICI !== exports.UNASSIGNED_STATE ? stateICI.index : 0;
57
+ if (indexInState === 0) {
58
+ return;
59
+ }
60
+ for (const undisposedICI of undisposedICIs.current) {
61
+ if (undisposedICI.index === indexInState) {
62
+ break;
63
+ }
64
+ undisposedICIs.current.delete(undisposedICI);
65
+ undisposedICI.cleanup();
66
+ }
67
+ });
68
+ (0, react_1.useEffect)(() => {
69
+ return function disposeAllRemainingItems() {
70
+ for (const undisposedICI of undisposedICIs.current) {
71
+ undisposedICI.cleanup();
72
+ }
73
+ };
74
+ }, []);
75
+ return {
76
+ setState: setStateAfterCommit,
77
+ state: stateICI !== exports.UNASSIGNED_STATE ? stateICI.item : exports.UNASSIGNED_STATE,
78
+ };
79
+ }
80
+ exports.useUpdatableDisposableState = useUpdatableDisposableState;
81
+ // @ts-ignore
82
+ function tsTests() {
83
+ const a = useUpdatableDisposableState();
84
+ // @ts-expect-error
85
+ a.setState([exports.UNASSIGNED_STATE, () => { }]);
86
+ // @ts-expect-error
87
+ a.setState(["asdf", () => { }]);
88
+ const b = useUpdatableDisposableState();
89
+ // @ts-expect-error
90
+ b.setState([exports.UNASSIGNED_STATE, () => { }]);
91
+ b.setState(["asdf", () => { }]);
92
+ }
@@ -0,0 +1,151 @@
1
+ # Using `react-disposable-state` and `reference-counted-pointer` to manage complicated state.
2
+
3
+ When managing more complicated state, such as an array of disposable items, we often do not want to dispose all of the previous state when creating new state.
4
+
5
+ For example, if our state goes from `[item1]` to `[item1, item2]`, it would be inefficient to dispose `item1` and re-create it. If the disposable item represents something like a network request, this may not even be possible!
6
+
7
+ In situations like this, we can wrap each item in a reference counted pointer. Then, when the state changes to `[item1, item2]` (actually from `[activeReferenceToItem1]` to `[activeReferenceToItem1, activeReferenceToItem2]`), then because there is always at least one undisposed active reference to `item1`, the underlying item is not disposed, and we do not need to recreate the item from scratch.
8
+
9
+ ## Example
10
+
11
+ Consider using `useUpdatableDisposableState` state to manage an array of disposable items.
12
+
13
+ The behavior of `useUpdatableDisposableState` is to entirely dispose of all previously-held items after a new item is set in state. So, if we had
14
+
15
+ ```js
16
+ const { state, setState } = useUpdatableDisposableState();
17
+ // assume state === [item1], and the cleanup function will dispose item1
18
+
19
+ const addItem2ToState = () => {
20
+ setState([item1, item2], () => {
21
+ disposeItem1();
22
+ disposeItem2();
23
+ });
24
+ };
25
+
26
+ return makePrettyJSX(state[0]);
27
+ ```
28
+
29
+ In the above, `item1` would be disposed after the state was updated to be `[item1, item2]`. Meaning, the hook returns an item that has already been disposed. Clearly, this will not do.
30
+
31
+ ## Alternatives
32
+
33
+ - We can re-create `item1` in `addItem2ToState`. This is costly, and not possible in all cases.
34
+ - We can store a mutable array in state, and dispose all items when the component unmounts. This is inefficient or unergonomic. Either:
35
+ - we never remove items from the array, meaning we inefficiently only clean up items when the component unmounts, or
36
+ - we dispose the items ourselves when we remove them. This is not ergonomic, and also means that the hook will likely misbehave in concurrent mode.
37
+ - Or, we can wrap each item in a reference counted pointer.
38
+
39
+ ## Reference counted pointers
40
+
41
+ A reference counted pointer is an object that wraps a disposable item, and keeps track of all active references to that item. When all active references have been removed, the item itself is disposed.
42
+
43
+ Given an undisposed active reference `r1`, one can get a new active reference `r2` and a cleanup function `cleanupR2` by cloning `r1`. If `r1`'s cleanup function is called, it will appear disposed, but the underlying item will not be disposed until all remaining cleanup functions are called. In particular, this means that `cleanupR2` must be called before the underlying item is disposed.
44
+
45
+ > `CacheItem<T>` is form of a reference counted pointer! Though, it's behavior is slightly different than what is described here.
46
+
47
+ > Note that in practice, you will never actually access the "original reference counted pointer". Instead, a properly designed API will simply return an active reference.
48
+
49
+ ## Using reference counted pointers in the example
50
+
51
+ Let's use reference counted pointers in the example.
52
+
53
+ ```js
54
+ const { state, setState } = useUpdatableDisposableState();
55
+ // assume state === [item1activeReference], and the cleanup function will dispose that active reference
56
+
57
+ const addItem2ToState = () => {
58
+ const [item2, disposeItem2] = createDisposeItem2();
59
+ const [item2ActiveReference, disposeItem2ActiveReference] =
60
+ createReferenceCountedPointer([item2, disposeItem2]);
61
+
62
+ // get a new active reference to the existing item1
63
+ const [item1ActiveReference, disposeItem1ActiveReference] = nullthrows(
64
+ state[0].cloneIfNotDisposed()
65
+ );
66
+ setState([item1ActiveReference, item2ActiveReference], () => {
67
+ disposeItem1ActiveReference();
68
+ disposeItem2ActiveReference();
69
+ });
70
+ };
71
+
72
+ return makePrettyJSX(nullthrows(state[0].getItemIfNotDisposed()));
73
+ ```
74
+
75
+ Not the most ergonomic, but it does the job.
76
+
77
+ ### When `item2` is added to state:
78
+
79
+ Let's discuss what happens with `item1` when we call `addItem2ToState`:
80
+
81
+ - First, a new active reference is created from the previous active reference to `item1`.
82
+ - Next, this item is set in state.
83
+ - When that state commits, the previous state's dispose function is called. This cleans up the first active reference to `item1`. However, because there is an undisposed active reference to `item1` (created during `addItem2ToState`), the item itself is not disposed.
84
+ - On subsequent renders, `item1` is still safe to access.
85
+ - When the component unmounts, the item in state is disposed. At this point, all remaining active references to `item1` will be disposed, and the item itself will be disposed.
86
+
87
+ ### When `item2` is removed from state:
88
+
89
+ Let's discuss what happens to `item2` if we removed `item2` from state with the following:
90
+
91
+ ```js
92
+ const removeItem2FromState = () => {
93
+ // assume state === [activeReferenceToItem1, activeReferenceToItem2]
94
+
95
+ // get a new active reference to the existing item1
96
+ const [item1ActiveReference, disposeItem1ActiveReference] = nullthrows(
97
+ state[0].cloneIfNotDisposed()
98
+ );
99
+ setState([item1ActiveReference], () => {
100
+ disposeItem1ActiveReference();
101
+ });
102
+ };
103
+ ```
104
+
105
+ - First, the new item is set in state.
106
+ - When the hook commits, it runs the previous dispose function. This disposes the active reference to `item2` created in `addItem2ToState`. Since there are no undisposed active references to `item2`, the underlying item is disposed.
107
+
108
+ Awesome!
109
+
110
+ ## Does this work with concurrent mode?
111
+
112
+ Yes! `useDisposableState` and `useUpdatableDisposableState` are compatible with concurrent mode.
113
+
114
+ ## Ergonomics
115
+
116
+ Unfortunately, reference counted pointers have worse DevEx than hooks.
117
+
118
+ Libraries built off of `react-disposable-state` and `reference-counted-pointers` may choose to expose more ergonomic hooks for application developers to use.
119
+
120
+ (In particular, it would probably be a hook's responsibility to call `nullthrows`, improving DevEx slighly. A properly written hook will not expose disposed items.)
121
+
122
+ Though, developers that want to precisely and correctly model application state may need to reach for the lower-level hooks.
123
+
124
+ ## When should you use reference counted pointers to manage disposable state?
125
+
126
+ Reference counted pointers are useful when you want structural sharing. Strutural sharing means that when your component transitions from `state1` to `state2`, the part of the states that is in common should not be disposed.
127
+
128
+ For example:
129
+
130
+ - Adding and removing items from an array or an object
131
+ - Re-using the same disposable item, but modifying other parts of state. Consider the state:
132
+
133
+ ```ts
134
+ type MyState =
135
+ | {
136
+ kind: "ConnectedToDatabase";
137
+ connectionToDatabase: ConnectionToDatabase;
138
+ currentUserId: number;
139
+ }
140
+ | {
141
+ kind: "DisconnectedFromDatabase";
142
+ };
143
+ ```
144
+
145
+ When we change the `currentUserId`, we should not dispose and re-create the database connection! So, the database connection should be managed by a reference counted pointer.
146
+
147
+ However, we also should not model this state as two pieces of state: an optional `ConnectionToDatabase` and an optional `currentUserId`, as that would allow us to have a user id but no connection, or a connection and no user id. (We are following the canonical rule: _Make impossible states unrepresentable_.)
148
+
149
+ - An external cache. Consider a cache that might cache the results of network requests, and a user who navigates to an item detail page, navigates away, and navigates back. If the item is still in the cache when the user navigates back, we can avoid an expensive network request.
150
+ - For this to work, the external cache might, instead of disposing the results of the network request, create a reference-counted pointer that is disposed after five minutes. If the user navigates back before the five minutes, they will be able to re-use the results.
151
+ - This is similar to how `CacheItem` works!
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@isograph/react-disposable-state",
3
+ "version": "0.0.4",
4
+ "description": "Primitives for managing disposable state in React",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "author": "Isograph Labs",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "compile": "rm -rf dist/* && tsc -p tsconfig.pkg.json",
11
+ "compile-watch": "tsc -p tsconfig.pkg.json --watch",
12
+ "test": "vitest run",
13
+ "test-watch": "vitest watch",
14
+ "coverage": "vitest run --coverage",
15
+ "note": "WE SHOULD ALSO TEST HERE",
16
+ "prepack": "yarn run compile"
17
+ },
18
+ "dependencies": {
19
+ "@isograph/disposable-types": "0.0.4",
20
+ "react": "^18.2.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "^18.0.31",
24
+ "react-test-renderer": "^18.2.0",
25
+ "vitest": "^0.29.8",
26
+ "typescript": "^5.0.3"
27
+ }
28
+ }