Skip to content

Commit

Permalink
Implement refs (#29)
Browse files Browse the repository at this point in the history
* closes bangle-io/bangle-issues-internal#36

* implement ref

* add docs
  • Loading branch information
kepta committed Oct 3, 2023
1 parent ce00cab commit 30dc93c
Show file tree
Hide file tree
Showing 13 changed files with 231 additions and 21 deletions.
1 change: 0 additions & 1 deletion documentation/pages/docs/advanced-topics/operations.mdx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
# Refs
# Ref

Ref (short for reference) is a special helper wrapper that allows you save value in a mutable fashion.
This allows you to persist data across multiple runs of an effects.
Ref (short for reference) is a mutable object that allows you save value without any reactivity.
This allows you to share data across multiple runs of an effects.

```typescript
### Usage

```ts {3}
import { ref } from 'nalanda';

const getValueRef = ref(initialValue);

effect((store) => {
const valueRef = getValueRef(store);
valueRef.current; // to get the value
valueRef.current = 'newValue'; // to set the value
});
```

### Basic Usage
### Sharing ref between effects

Here is a basic example where ref is used to manage the abort a request across multiple effects.
Here is an example where a shared ref is used to manage aborting of a request across multiple effects.

```ts
import { ref } from 'nalanda';

const key = createKey('mySlice', []);
const getAbortRef = ref(new AbortController());

effect(async (store) => {
key.effect(async (store) => {
const abortRef = getAbortRef(store);

if (!abortRef.current.signal.aborted) {
Expand All @@ -37,7 +39,7 @@ effect(async (store) => {
});

// an effect that can cancel the fetch
effect(async (store) => {
key.effect(async (store) => {
const abortRef = getAbortRef(store);
const { isUserLoggedIn } = useSlice.track(store);

Expand All @@ -48,6 +50,26 @@ effect(async (store) => {
});
```

### Typescript

You pass a type argument to `ref` to specify the type of the value you want to store.

```ts /<string |undefined>/
const getUsernameRef = ref<string |undefined>(undefined)

key.effect(async (store) => {
const usernameRef = getUsernameRef(store);

// fetch username if not set
if (!usernameRef.current) {
const username = await fetchUsername();
usernameRef.current = username;
}
})
```



### When to use them?

Refs share a lot of similarities with state. However, they are not the same. By default its a **good** idea to use slice state whever possible.
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { createStore, createKey } from './vanilla/index';
export { createStore, createKey, ref, cleanup } from './vanilla/index';
2 changes: 1 addition & 1 deletion src/vanilla/__tests__/effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import waitForExpect from 'wait-for-expect';
import { createKey } from '../slice/key';
import { createStore } from '../store';
import { EffectScheduler, effect } from '../effect/effect';
import { cleanup } from '../cleanup';
import { cleanup } from '../effect/cleanup';

beforeEach(() => {
testCleanup();
Expand Down
153 changes: 153 additions & 0 deletions src/vanilla/__tests__/ref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Store, createStore } from '../store';
import waitForExpect from 'wait-for-expect';
import { testCleanup } from '../helpers/test-cleanup';
import { createKey } from '../slice/key';
import { ref } from '../effect/ref';
import { EffectStore } from '../effect/effect';

const sliceAKey = createKey('sliceA', []);
const sliceAField1 = sliceAKey.field('value:sliceAField1');
const sliceAField2 = sliceAKey.field('value:sliceAField2');

const sliceA = sliceAKey.slice({
fields: {
sliceAField1,
sliceAField2,
},
});

const sliceBKey = createKey('sliceB', []);
const sliceBField1 = sliceBKey.field('value:sliceBField1');

const sliceB = sliceBKey.slice({
fields: {
sliceBField1,
},
});

beforeEach(() => {
testCleanup();
});

test('ref works', async () => {
const myStore = createStore({
name: 'myStore',
slices: [sliceA, sliceB],
});

const getMyRef = ref<{ foo: { counter?: number } }>(() => ({
foo: {},
}));

let derStore: EffectStore | undefined;

myStore.effect((store) => {
derStore = store;
const val = sliceA.track(store);
const myRef = getMyRef(store);

myRef.current.foo.counter = 1;
});

expect(getMyRef(myStore).current).toEqual({
foo: {},
});

// effect is deferred so we need to wait for it to run
await waitForExpect(() => {
expect(getMyRef(derStore!).current.foo.counter).toBe(1);
});
});

test('creating another store does not reuse the ref value', async () => {
const myStore = createStore({
name: 'myStore',
slices: [sliceA, sliceB],
});

const myStore2 = createStore({
name: 'myStore2',
slices: [sliceA, sliceB],
});

const getMyRef = ref<{ foo: { counter?: number } }>(() => ({
foo: {},
}));

let derStore: EffectStore | undefined;
let derStore2: EffectStore | undefined;

myStore.effect((store) => {
derStore = store;
const val = sliceA.track(store);

const myRef = getMyRef(store);

myRef.current.foo.counter = 1;
});

myStore2.effect((store) => {
derStore2 = store;
const myRef = getMyRef(store);

myRef.current.foo.counter = 99;
});

expect(getMyRef(myStore).current).toEqual({
foo: {},
});

// effect is deferred so we need to wait for it to run
await waitForExpect(() => {
expect(getMyRef(derStore!).current.foo.counter).toBe(1);
});

// effect is deferred so we need to wait for it to run
await waitForExpect(() => {
expect(getMyRef(derStore2!).current.foo.counter).toBe(99);
});
});

test('multiple effects can share the ref value', async () => {
const myStore = createStore({
name: 'myStore',
slices: [sliceA, sliceB],
});

const getMyRef = ref<{ foo: { counter?: number } }>(() => ({
foo: {},
}));

let derStore: EffectStore | undefined;
let derStore2: EffectStore | undefined;

myStore.effect((store) => {
derStore = store;
const val = sliceA.track(store).sliceAField1;

const myRef = getMyRef(store);

myRef.current.foo.counter = 1;
});

sliceBKey.effect((store) => {
derStore2 = store;
const val = sliceB.track(store).sliceBField1;

const myRef = getMyRef(store);

if (myRef.current.foo.counter === 1) {
myRef.current.foo.counter = 2;
}
});

expect(getMyRef(myStore).current).toEqual({
foo: {},
});

await waitForExpect(() => {
expect(getMyRef(myStore).current.foo.counter).toBe(2);
expect(getMyRef(derStore2!).current.foo.counter).toBe(2);
expect(getMyRef(derStore!).current.foo.counter).toBe(2);
});
});
3 changes: 3 additions & 0 deletions src/vanilla/base-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Operation } from './effect/operation';
import type { Store } from './store';
import type { StoreState } from './store-state';
import type { Transaction } from './transaction';

Expand All @@ -12,5 +13,7 @@ export type Dispatch = (
export abstract class BaseStore {
abstract readonly state: StoreState;

abstract _rootStore: Store;

abstract dispatch(txn: Transaction | Operation): void;
}
6 changes: 3 additions & 3 deletions src/vanilla/cleanup.ts → src/vanilla/effect/cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EffectStore } from './effect/effect';
import { OperationStore } from './effect/operation';
import { throwValidationError } from './helpers/throw-error';
import { EffectStore } from './effect';
import { OperationStore } from './operation';
import { throwValidationError } from '../helpers/throw-error';

export type CleanupCallback = () => void | Promise<void>;

Expand Down
2 changes: 1 addition & 1 deletion src/vanilla/effect/effect-run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CleanupCallback } from '../cleanup';
import type { CleanupCallback } from './cleanup';
import type { BaseField } from '../slice/field';
import type { Store } from '../store';

Expand Down
5 changes: 1 addition & 4 deletions src/vanilla/effect/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ export type EffectOpts = {

export class EffectStore extends BaseStore {
constructor(
/**
* @internal
*/
private _rootStore: Store,
public _rootStore: Store,
public readonly name: string,
/**
* @internal
Expand Down
4 changes: 3 additions & 1 deletion src/vanilla/effect/operation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseStore } from '../base-store';
import type { CleanupCallback } from '../cleanup';
import type { CleanupCallback } from './cleanup';
import { Store } from '../store';
import type { Transaction } from '../transaction';

Expand All @@ -14,12 +14,14 @@ export class OperationStore extends BaseStore {
private cleanupRan = false;
private readonly _cleanupCallbacks: Set<CleanupCallback> = new Set();

_rootStore: Store;
constructor(
private rootStore: Store,
public readonly name: string,
private readonly opts: OperationOpts,
) {
super();
this._rootStore = rootStore;
}

get state() {
Expand Down
27 changes: 27 additions & 0 deletions src/vanilla/effect/ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { BaseStore } from '../base-store';
import { Store } from '../store';

export type RefObject<T> = {
current: T;
};

export function ref<T>(
init: () => T,
): (store: Store | BaseStore) => RefObject<T> {
const cache = new WeakMap<Store, RefObject<T>>();

return (store) => {
const rootStore: Store = store instanceof Store ? store : store._rootStore;

let existing = cache.get(rootStore);

if (!existing) {
existing = {
current: init(),
};
cache.set(rootStore, existing);
}

return existing;
};
}
2 changes: 2 additions & 0 deletions src/vanilla/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { cleanup } from './effect/cleanup';
export { createKey } from './slice/key';
export { createStore } from './store';
export { ref } from './effect/ref';
5 changes: 5 additions & 0 deletions src/vanilla/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function createStore(config: StoreOptions) {

export class Store extends BaseStore {
private _state: StoreState;

_rootStore: Store;
public readonly initialState: StoreState;

private effectsManager: EffectManager;
Expand All @@ -77,6 +79,7 @@ export class Store extends BaseStore {

constructor(public readonly options: StoreOptions) {
super();

this._state = StoreState.create({
slices: options.slices,
});
Expand All @@ -90,6 +93,8 @@ export class Store extends BaseStore {
debug: this.options.debug,
});

this._rootStore = this;

// do it a bit later so that all effects are registered
queueMicrotask(() => {
this.options.slices.forEach((slice) => {
Expand Down

0 comments on commit 30dc93c

Please sign in to comment.