Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/ninety-actors-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/angular-store': minor
'@tanstack/preact-store': minor
'@tanstack/react-store': minor
'@tanstack/solid-store': minor
'@tanstack/vue-store': minor
'@tanstack/store': minor
'@tanstack/svelte-store': minor
---

Added atom.whileWatched(cb), store.whileWatched(cb), createExternalStoreAtom
8 changes: 8 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@
"label": "StoreActionsFactory",
"to": "reference/type-aliases/StoreActionsFactory"
},
{
"label": "WatchedEffect",
"to": "reference/type-aliases/WatchedEffect"
},
{
"label": "batch",
"to": "reference/functions/batch"
Expand All @@ -237,6 +241,10 @@
"label": "createAtom",
"to": "reference/functions/createAtom"
},
{
"label": "createExternalStoreAtom",
"to": "reference/functions/createExternalStoreAtom"
},
{
"label": "createStore",
"to": "reference/functions/createStore"
Expand Down
58 changes: 58 additions & 0 deletions docs/reference/functions/createExternalStoreAtom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
id: createExternalStoreAtom
title: createExternalStoreAtom
---

# Function: createExternalStoreAtom()

```ts
function createExternalStoreAtom<T>(
getSnapshot,
subscribe,
options?): ReadonlyAtom<T>;
```

Defined in: [atom.ts:190](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L190)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Like React.useSyncExternalStore: pulls external state into an atom.
Thic can be used for interoperating with other state management libraries.

```ts
import * as redux from "redux"

const reduxStore = redux.createStore((state: number, action: number) => state + action, 0)
const atom = createExternalStoreAtom(reduxStore.getState, reduxStore.subscribe)

const timesTwo = createAtom(() => atom.get() * 2)
timesTwo.subscribe((value) => {
console.log('timesTwo: ', value)
})

reduxStore.dispatch(1)
// timesTwo: 2
reduxStore.dispatch(1)
// timesTwo: 4

## Type Parameters
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### T

`T`

## Parameters

### getSnapshot

() => `T`

### subscribe

(`onStoreChange`) => () => `void`

### options?

[`AtomOptions`](../interfaces/AtomOptions.md)\<`T`\>

## Returns

[`ReadonlyAtom`](../interfaces/ReadonlyAtom.md)\<`T`\>
19 changes: 19 additions & 0 deletions docs/reference/type-aliases/WatchedEffect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
id: WatchedEffect
title: WatchedEffect
---

# Type Alias: WatchedEffect()

```ts
type WatchedEffect = () => () => void | void | undefined;
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type signature rendering is inaccurate.

The doc currently implies the callback always returns a function. It should reflect (() => void) | void | undefined.

🛠️ Proposed signature/returns fix
-type WatchedEffect = () => () => void | void | undefined;
+type WatchedEffect = () => (() => void) | void | undefined;
...
-() => `void` \| `void` \| `undefined`
+(() => `void`) \| `void` \| `undefined`

Also applies to: 17-19

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/type-aliases/WatchedEffect.md` around lines 9 - 10, The
documented type signature for WatchedEffect is wrong: update the rendered
signature to indicate the callback may return either a cleanup function (() =>
void), void, or undefined (i.e., (() => void) | void | undefined) so it doesn't
imply a function is always returned; change the WatchedEffect documentation and
any other occurrences (notably the similar entries around lines 17-19) to use
that corrected union form and ensure the prose matches this behavior.


Defined in: [atom.ts:33](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L33)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Called when the atom is watched.
Returns a cleanup function that will be called when the atom is unwatched.

## Returns

() => `void` \| `void` \| `undefined`
3 changes: 3 additions & 0 deletions packages/store/src/alien.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ export const enum ReactiveFlags {
export function createReactiveSystem({
update,
notify,
watched,
unwatched,
}: {
update(sub: ReactiveNode): boolean
notify(sub: ReactiveNode): void
watched(sub: ReactiveNode): void
unwatched(sub: ReactiveNode): void
}) {
return {
Expand Down Expand Up @@ -95,6 +97,7 @@ export function createReactiveSystem({
prevSub.nextSub = newLink
} else {
dep.subs = newLink
watched(dep)
}
}

Expand Down
88 changes: 88 additions & 0 deletions packages/store/src/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,29 @@ export function toObserver<T>(
}
}

/**
* Called when the atom is watched.
* Returns a cleanup function that will be called when the atom is unwatched.
*/
export type WatchedEffect = () => (() => void) | void | undefined

interface InternalAtom<T> extends ReactiveNode {
_snapshot: T
_update: (getValue?: T | ((snapshot: T) => T)) => boolean

get: () => T
subscribe: (observerOrFn: Observer<T> | ((value: T) => void)) => Subscription
/**
* `effect` will be called while the atom is watched. `effect` may return a
* cleanup function, which will be called when the atom is unwatched.
*
* Returns a `stop` function which cancels the listener.
*/
whileWatched: (effect: WatchedEffect) => () => void

_watched: boolean
_watchedSubs?: Array<WatchedEffect>
_watchedCleanups?: Array<(() => void) | void | undefined>
}

const queuedEffects: Array<Effect | undefined> = []
Expand All @@ -45,7 +63,21 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } =
queuedEffects[queuedEffectsLength++] = effect
effect.flags &= ~ReactiveFlags.Watching
},
watched(atom: InternalAtom<any>): void {
atom._watched = true
if (atom._watchedSubs?.length) {
atom._watchedCleanups = atom._watchedSubs.map((sub) => sub())
}
},
unwatched(atom: InternalAtom<any>): void {
atom._watched = false
// not sure if this should go above or below the deps purge
// preorder vs postorder
if (atom._watchedCleanups?.length) {
atom._watchedCleanups.forEach((cleanup) => cleanup?.())
atom._watchedCleanups.length = 0
}
Comment thread
justjake marked this conversation as resolved.
Outdated

if (atom.depsTail !== undefined) {
atom.depsTail = undefined
atom.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty
Expand Down Expand Up @@ -135,6 +167,42 @@ export function createAsyncAtom<T>(
return atom
}

/**
* Like React.useSyncExternalStore: pulls external state into an atom.
* Thic can be used for interoperating with other state management libraries.
*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* ```ts
* import * as redux from "redux"
*
* const reduxStore = redux.createStore((state: number, action: number) => state + action, 0)
* const atom = createExternalStoreAtom(reduxStore.getState, reduxStore.subscribe)
*
* const timesTwo = createAtom(() => atom.get() * 2)
* timesTwo.subscribe((value) => {
* console.log('timesTwo: ', value)
* })
*
* reduxStore.dispatch(1)
* // timesTwo: 2
* reduxStore.dispatch(1)
* // timesTwo: 4
*/
export function createExternalStoreAtom<T>(
getSnapshot: () => T,
subscribe: (onStoreChange: () => void) => () => void,
options?: AtomOptions<T>,
): ReadonlyAtom<T> {
const trigger = createAtom(0)
const invalidate = () => trigger.set((n) => n + 1)
trigger.whileWatched(() => subscribe(invalidate))

return createAtom(() => {
// Return latest snapshot when `trigger` changes
trigger.get()
return getSnapshot()
}, options)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function createAtom<T>(
getValue: (prev?: NoInfer<T>) => T,
options?: AtomOptions<T>,
Expand All @@ -153,6 +221,7 @@ export function createAtom<T>(
// Create plain object atom
const atom: InternalAtom<T> = {
_snapshot: isComputed ? undefined! : valueOrFn,
_watched: false,

subs: undefined,
subsTail: undefined,
Expand Down Expand Up @@ -185,6 +254,25 @@ export function createAtom<T>(
},
}
},

whileWatched(listener: WatchedEffect): () => void {
atom._watchedSubs ??= []
atom._watchedSubs.push(listener)
if (atom._watched) {
atom._watchedCleanups ??= []
atom._watchedCleanups.push(listener())
}
return () => {
if (!atom._watchedSubs) {
return
}
const index = atom._watchedSubs.indexOf(listener)
if (index !== -1) {
atom._watchedSubs.splice(index, 1)
}
Comment thread
justjake marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},

_update(getValue?: T | ((snapshot: T) => T)): boolean {
const prevSub = activeSub
const compare = options?.compare ?? Object.is
Expand Down
3 changes: 3 additions & 0 deletions packages/store/src/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } =
queued[insertIndex] = left
}
},
watched(_node) {
// whileWatched not implemented for signal.ts
},
unwatched(node) {
if (!(node.flags & ReactiveFlags.Mutable)) {
effectScopeOper.call(node)
Expand Down
19 changes: 19 additions & 0 deletions packages/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createAtom, toObserver } from './atom'
import type { WatchedEffect } from './atom'
import type { Atom, Observer, Subscription } from './types'

export type StoreAction = (...args: Array<any>) => any
Expand Down Expand Up @@ -54,6 +55,15 @@ export class Store<T, TActions extends StoreActionMap = never> {
): Subscription {
return this.atom.subscribe(toObserver(observerOrFn))
}
/**
* `effect` will be called while the atom is watched. `effect` may return a
* cleanup function, which will be called when the atom is unwatched.
*
* Returns a `stop` function which cancels the listener.
*/
public whileWatched(effect: WatchedEffect): () => void {
return this.atom.whileWatched(effect)
}
}

export class ReadonlyStore<T> implements Omit<
Expand Down Expand Up @@ -81,6 +91,15 @@ export class ReadonlyStore<T> implements Omit<
): Subscription {
return this.atom.subscribe(toObserver(observerOrFn))
}
/**
* `effect` will be called while the atom is watched. `effect` may return a
* cleanup function, which will be called when the atom is unwatched.
*
* Returns a `stop` function which cancels the listener.
*/
public whileWatched(effect: WatchedEffect): () => void {
return this.atom.whileWatched(effect)
}
}

export function createStore<T>(
Expand Down
32 changes: 31 additions & 1 deletion packages/store/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WatchedEffect } from './atom'
import type { ReactiveNode } from './alien'

export type Selection<TSelected> = Readable<TSelected>
Expand Down Expand Up @@ -30,7 +31,36 @@ export interface Readable<T> extends Subscribable<T> {
get: () => T
}

export interface BaseAtom<T> extends Subscribable<T>, Readable<T> {}
export interface BaseAtom<T> extends Subscribable<T>, Readable<T> {
/**
* `effect` will be called when the first subscriber is added. `effect`
* may return a cleanup function, which will be called when the atom is
* unwatched.
*
* Returns an `cleanup` function which removes the `whileWatched` listener.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*
* This can be used to sync external resources into the atom, similar to
* `useLayoutEffect` in React.
*
* ```ts
* function createTicker(ms: number) {
* const now = createAtom(Date.now())
* const refresh = () => now.set(Date.now())
* now.whileWatched(() => {
* refresh()
* const interval = setInterval(refresh, ms)
* return () => clearInterval(interval)
* })
* return createAtom(() => now.get())
* }
* const ticker = createTicker(1000)
* ticker.subscribe(() => {
* console.log('current time: ', ticker.get())
* })
* ```
*/
whileWatched: (effect: WatchedEffect) => () => void
}

export interface InternalBaseAtom<T> extends Subscribable<T>, Readable<T> {
/** @internal */
Expand Down
Loading