Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/tough-brooms-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate': minor
---

Add actor select function simliar to @xstate/store
23 changes: 23 additions & 0 deletions packages/core/src/createActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
EventFromLogic,
InputFrom,
IsNotNever,
Readable,
Snapshot,
SnapshotFrom
} from './types.ts';
Expand Down Expand Up @@ -482,6 +483,28 @@ export class Actor<TLogic extends AnyActorLogic>
};
}

public select<TSelected, TSnapshot = SnapshotFrom<TLogic>>(
selector: (snapshot: TSnapshot) => TSelected,
equalityFn: (a: TSelected, b: TSelected) => boolean = Object.is
): Readable<TSelected> {
return {
subscribe: (observerOrFn) => {
const observer = toObserver(observerOrFn);
const snapshot: TSnapshot = this.getSnapshot();
let previousSelected = selector(snapshot);

return this.subscribe((snapshot) => {
const nextSelected = selector(snapshot);
if (!equalityFn(previousSelected, nextSelected)) {
previousSelected = nextSelected;
observer.next?.(nextSelected);
}
});
},
get: () => selector(this.getSnapshot())
};
}

/** Starts the Actor from the initial state */
public start(): this {
if (this._processingStatus === ProcessingStatus.Running) {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,12 @@ export interface Subscription {
unsubscribe(): void;
}

export type Selection<TSelected> = Readable<TSelected>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is unused, do we need this alias? it probably could be dropped

Copy link
Author

Choose a reason for hiding this comment

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

Yes, good catch! Removing it.


export interface Readable<T> extends Subscribable<T> {
get: () => T;
}

export interface InteropObservable<T> {
[Symbol.observable]: () => InteropSubscribable<T>;
}
Expand Down Expand Up @@ -1990,6 +1996,10 @@ export interface ActorRef<
emitted: TEmitted & (TType extends '*' ? unknown : { type: TType })
) => void
) => Subscription;
select<TSelected, TSnapshot>(
Copy link
Collaborator

Choose a reason for hiding this comment

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

TSnapshot is already available in the scope here (it's an existing type parameter of ActorRef), so u dont need it here - only TSelected is needed

Copy link
Author

@Uniqen Uniqen Jun 22, 2025

Choose a reason for hiding this comment

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

I get compilation error when I remove TSnapshot and then run pnpm typecheck, see GitHub Actions. Not sure on how to resolve it. Reverting to use TSnapshot for now.

I looked into adding a generic for TSnapshot on Actor, see below. But that ended up in a lot of changes not really in the scope of this PR. @Andarist any suggestions?

export class Actor<TLogic extends AnyActorLogic, TSnapshot extends SnapshotFrom<TLogic>>
  implements
    ActorRef<SnapshotFrom<TLogic>, EventFromLogic<TLogic>, EmittedFrom<TLogic>>
{
  ....
}

Copy link
Author

Choose a reason for hiding this comment

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

@Andarist Any idea how to resolve this without a major refactor? Is it required in order to merge the PR?

selector: (snapshot: TSnapshot) => TSelected,
equalityFn?: (a: TSelected, b: TSelected) => boolean
): Readable<TSelected>;
}

export type AnyActorRef = ActorRef<
Expand Down
275 changes: 275 additions & 0 deletions packages/core/test/select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { assign, SnapshotFrom } from '../src';
import { createMachine } from '../src/index.ts';
import { createActor } from '../src/index.ts';

describe('select', () => {
it('should get current value', () => {
const machine = createMachine({
types: {} as { context: { data: number } },
context: { data: 42 },
initial: 'G',
states: {
G: {
on: {
INC: {
actions: assign({ data: ({ context }) => context.data + 1 })
}
}
}
}
});

const service = createActor(machine).start();
const selection = service.select(({ context }) => context.data);

expect(selection.get()).toBe(42);

service.send({ type: 'INC' });

expect(selection.get()).toBe(43);
});

it('should subscribe to changes', () => {
const machine = createMachine({
types: {} as { context: { data: number } },
context: { data: 42 },
initial: 'G',
states: {
G: {
on: {
INC: {
actions: assign({ data: ({ context }) => context.data + 1 })
}
}
}
}
});

const callback = vi.fn();
const service = createActor(machine).start();
const selection = service.select(({ context }) => context.data);
selection.subscribe(callback);

service.send({ type: 'INC' });

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(43);
});

it('should not notify if selected value has not changed', () => {
const machine = createMachine({
types: {} as { context: { data: number; other: string } },
context: { data: 42, other: 'foo' },
initial: 'G',
states: {
G: {
on: {
INC: {
actions: assign({ data: ({ context }) => context.data + 1 })
}
}
}
}
});

const callback = vi.fn();
const service = createActor(machine).start();
const selection = service.select(({ context }) => context.other);
selection.subscribe(callback);

service.send({ type: 'INC' });

expect(callback).not.toHaveBeenCalled();
});

it('should support custom equality function', () => {
const machine = createMachine({
types: {} as {
context: { age: number; name: string };
events:
| {
type: 'UPDATE_NAME';
name: string;
}
| {
type: 'UPDATE_AGE';
age: number;
};
},
context: { age: 42, name: 'John' },
initial: 'G',
states: {
G: {
on: {
UPDATE_NAME: {
actions: assign({ name: ({ event }) => event.name })
},
UPDATE_AGE: {
actions: assign({ age: ({ event }) => event.age })
}
}
}
}
});

const service = createActor(machine).start();

const callback = vi.fn();
const selector = ({ context }: SnapshotFrom<typeof machine>) => ({
name: context.name,
age: context.age
});
const equalityFn = (a: { name: string }, b: { name: string }) =>
a.name === b.name; // Only compare names

service.select(selector, equalityFn).subscribe(callback);

service.send({ type: 'UPDATE_AGE', age: 66 });
expect(callback).not.toHaveBeenCalled();

service.send({ type: 'UPDATE_NAME', name: 'Jane' });
expect(callback).toHaveBeenCalledTimes(1);
});

it('should unsubscribe correctly', () => {
const machine = createMachine({
types: {} as { context: { data: number } },
context: { data: 42 },
initial: 'G',
states: {
G: {
on: {
INC: {
actions: assign({ data: ({ context }) => context.data + 1 })
}
}
}
}
});

const service = createActor(machine).start();

const callback = vi.fn();
const selection = service.select(({ context }) => context.data);
const subscription = selection.subscribe(callback);

subscription.unsubscribe();
service.send({ type: 'INC' });

expect(callback).not.toHaveBeenCalled();
});

it('should handle updates with multiple subscribers', () => {
interface PositionContext {
position: {
x: number;
y: number;
};
}

const machine = createMachine({
types: {} as {
context: {
user: { age: number; name: string };
position: {
x: number;
y: number;
};
};
events:
| {
type: 'UPDATE_USER';
user: { age: number; name: string };
}
| {
type: 'UPDATE_POSITION';
position: {
x: number;
y: number;
};
};
},
context: { position: { x: 0, y: 0 }, user: { name: 'John', age: 30 } },
initial: 'G',
states: {
G: {
on: {
UPDATE_USER: {
actions: assign({ user: ({ event }) => event.user })
},
UPDATE_POSITION: {
actions: assign({ position: ({ event }) => event.position })
}
}
}
}
});

const store = createActor(machine).start();

// Mock DOM manipulation callback
const renderCallback = vi.fn();
store
.select(({ context }) => context.position)
.subscribe((position) => {
renderCallback(position);
});

// Mock logger callback for x position only
const loggerCallback = vi.fn();
store
.select(({ context }) => context.position.x)
.subscribe((x) => {
loggerCallback(x);
});

// Simulate position update
store.send({
type: 'UPDATE_POSITION',
position: { x: 100, y: 200 }
});

// Verify render callback received full position update
expect(renderCallback).toHaveBeenCalledTimes(1);
expect(renderCallback).toHaveBeenCalledWith({ x: 100, y: 200 });

// Verify logger callback received only x position
expect(loggerCallback).toHaveBeenCalledTimes(1);
expect(loggerCallback).toHaveBeenCalledWith(100);

// Simulate another update
store.send({
type: 'UPDATE_POSITION',
position: { x: 150, y: 300 }
});

expect(renderCallback).toHaveBeenCalledTimes(2);
expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 300 });
expect(loggerCallback).toHaveBeenCalledTimes(2);
expect(loggerCallback).toHaveBeenLastCalledWith(150);

// Simulate changing only the y position
store.send({
type: 'UPDATE_POSITION',
position: { x: 150, y: 400 }
});

expect(renderCallback).toHaveBeenCalledTimes(3);
expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 400 });

// loggerCallback should not have been called
expect(loggerCallback).toHaveBeenCalledTimes(2);

// Simulate changing only the user
store.send({
type: 'UPDATE_USER',
user: { name: 'Jane', age: 25 }
});

// renderCallback should not have been called
expect(renderCallback).toHaveBeenCalledTimes(3);

// loggerCallback should not have been called
expect(loggerCallback).toHaveBeenCalledTimes(2);
});
});