summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md169
-rw-r--r--src/shapex.test.ts416
-rw-r--r--src/shapex.ts292
3 files changed, 311 insertions, 566 deletions
diff --git a/README.md b/README.md
index 0669c12..8682da0 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,17 @@
# ShapeX
-Create scalable event-driven applications with ShapeX, inspired by [re-frame](https://github.com/day8/re-frame/). ShapeX uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really anywhere where JavaScript runs.
+Create scalable event-driven applications with ShapeX, inspired by [re-frame](https://github.com/day8/re-frame/). ShapeX
+uses zero dependencies and is runtime agnostic, meaning that you can use it in Node, Deno, Bun, browsers, or really
+anywhere where JavaScript runs.
## Example application
-This is an example application that demonstrates how to use the ShapeX library. It has a single starting point event called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber for the `counter` state fires.
+This is an example application that demonstrates how to use the ShapeX library. It has a single starting point event
+called `request`, which returns an updated state, which changes the `counter`. When that state changes, the subscriber
+for the `counter` state fires.
```typescript
-import { ShapeX } from "shapex";
+import {ShapeX} from "shapex";
type AppState = {
counter: number;
@@ -19,19 +23,13 @@ const app = ShapeX<AppState>({
app.subscribe("$.counter", (state) => {
console.log("counter changed", state);
-
- return {
- state,
- };
});
app.subscribe("request", (state) => {
- return {
- state: {
- ...state,
- counter: state.counter + 1;
- }
- }
+ app.setState({
+ ...state,
+ counter: state.counter + 1
+ });
});
// Dispatch an event somewhere.
@@ -51,7 +49,7 @@ npm install shapex
At the core of your application is state. You start by initiating ShapeX with some initial state, like so:
```typescript
-import { ShapeX } from "shapex";
+import {ShapeX} from "shapex";
type AppState = {
counter: number;
@@ -69,162 +67,85 @@ You can model your `AppState` however you like. It does not have to be called `A
Events set things in motion. You can dispatch events like so:
```typescript
-app.dispatch("some-event-name");
+app.dispatch("some-topic-name");
```
-And, if there's a subscription for that event name, that subscription will then fire. The above example is a data-less event, but you can also dispatch events with data, like so:
+And, if there's any subscriptions for that topic, those subscriptions will then fire their event listeners.
+The above example is an event with no payload, but you can also dispatch events with payload, like so:
```typescript
-app.dispatch("some-event-name", {
+app.dispatch("some-topic-name", {
hello: "world",
});
```
### Subscriptions
-Subscriptions listen to events or changes to state. Each subscription must return a `SubscriptionResponse` object, which looks like this:
-
-```typescript
-{
- state: T, // optional
- dispatch: {
- to: "event-to-dispatch",
- with: {} // optional
- } // optional
-}
-```
-
-You can also dispatch multiple events by passing an array of objects, like so:
-
-```typescript
-{
- state: T,
- dispatch: [{
- to: "event-to-dispatch",
- with: {}
- },{
- to: "another-event-to-dispatch",
- with: {}
- }]
-}
-```
-
-#### Event subscriptions
-
You can listen to events like so:
```typescript
-app.subscribe("some-event-name", (state, data: <{hello: string}>) => {
- return {
- state,
- };
+app.subscribe("some-event-name", (state, payload) => {
+ // do something with the payload
});
```
-Each subscription has a callback function which gets passed to it the app state and whatever data was passed
-when the event was dispatched. Subscription callbacks must return an `Response` which consists of updated state and/or further event dispatches. If you don't want to update state, just return the same state that the callback got in the first place.
+Each subscription has a callback function (event listener) which gets passed to it the app state and whatever payload
+was passed when the event was dispatched. In other words, subscriptions take a `EventListener<TState, TPayload>`
+function where `TState` is the app state, `TPayload` is the data sent via the `dispatch` method.
#### State change subscriptions
-You can also listen to state changes with subscriptions, which will fire when the listened state changes. You can listen to state changes like so:
+You can also listen to state changes with subscriptions, which will fire when the listened state changes. You can listen
+to state changes like so:
```typescript
app.subscribe("$.counter", (state) => {
- return {
- state,
- };
+ // state.counter changed
});
```
-Notable difference here is the `$.` prefix in the subscription listener name, which tells ShapeX what state to look for. Here `$.counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`) followed by the key name, i.e: `$.counter.nestedKey`. Additionally, state change subscriptions do not get any additional data passed to them, only state.
+Notable difference here is the `$.` prefix in the subscription listener name, which tells ShapeX what state to look for.
+Here `$.counter` will look for the root-level `counter` key in state. To look for nested state, simply add a dot (`.`)
+followed by the key name, i.e: `$.counter.nestedKey`. Additionally, state change subscriptions do not get any additional
+data passed to them, only state, or in other words they are of `EventListener<TState>` type.
#### Subscribe only once
-If you want to subscribe to an event or state change only once, you can use the `subscribeOnce` method. This method works similarly to `subscribe`, but it will automatically unsubscribe after the first event or state change.
+If you want to subscribe to an event or state change only once, you can use the `subscribeOnce` method. This method
+works similarly to `subscribe`, but it will automatically unsubscribe after the first event or state change.
```typescript
app.subscribeOnce("$.counter", (state) => {
- return {
- state,
- };
+ // This will run only once.
});
```
#### Unsubscribe
-If you want to unsubscribe from an event or state change, you can use the `unsubscribe` method. This method takes the event or state change name as its argument and removes the subscription.
+If you want to unsubscribe from an event or state change, you can use the `unsubscribe` method. This method takes the
+event or state change name as its argument and removes the subscription.
```typescript
-app.unsubscribe("counter++");
+app.unsubscribe("some-topic-name");
```
-#### Change state
+### Updating state
-You can change state by returning a new state object, like so:
+You can update state with the `setState` method:
```typescript
app.subscribe("counter++", (state) => {
- return {
- state: {
- ...state,
- counter: state.counter + 1,
- },
- };
+ app.setState({
+ ...state,
+ counter: state.counter + 1
+ });
});
```
-#### Dispatch events from subscriptions
-
-You can also dispatch events from within subscriptions, like so:
-
-```typescript
-app.subscribe("counter++", (state) => {
- return {
- state: {
- ...state,
- counter: state.counter + 1,
- },
- };
-});
-
-app.subscribe("some-event-name", (state) => {
- return {
- state,
- dispatch: {
- to: "counter++",
- },
- };
-});
-```
-
-Now if `some-event-name` is dispatched, it also dispatches `counter++`. You can also pass data along, like so:
-
-```typescript
-app.subscribe("counter-increase", (state, increase: number) => {
- return {
- state: {
- ...state,
- counter: state.counter + increase,
- },
- };
-});
-
-app.subscribe("some-event-name", (state) => {
- return {
- state,
- dispatch: {
- to: "counter-increase",
- with: 5,
- },
- };
-});
-```
-
-So now if `some-event-name` is dispatched, it also dispatches `counter-increase` with an increase of 5.
-
#### Get the subscription count
-If you want to get the number of subscriptions for a specific event or state change, you can use the `subscriptionCount` method. This method takes the event or state change name as its argument and returns the number of subscriptions.
+If you want to get the number of subscriptions for a specific event or state change, you can use the `subscriptionCount`
+method. This method takes the event or state change name as its argument and returns the number of subscriptions.
```typescript
// State change subscriptions
@@ -236,7 +157,8 @@ app.subscriptionCount("some-event-name");
#### Get all subscriptions
-If you want to get all subscriptions, you can use the `subscriptions` method. This method returns an array of all the subscription names.
+If you want to get all subscriptions, you can use the `subscriptions` method. This method returns an array of all the
+subscription names.
```typescript
app.subscriptions();
@@ -244,7 +166,8 @@ app.subscriptions();
#### Get current app state
-If you want to get the current state of the app, you can use the `state` method. This method returns the current state of the app.
+If you want to get the current state of the app, you can use the `state` method. This method returns the current state
+of the app.
```typescript
app.state();
diff --git a/src/shapex.test.ts b/src/shapex.test.ts
index 00cf1e6..83715c2 100644
--- a/src/shapex.test.ts
+++ b/src/shapex.test.ts
@@ -1,27 +1,30 @@
-import { describe, it, expect, vi } from "vitest";
-import { ShapeX, type EventCallback } from "./shapex.ts";
+import {describe, it, expect, vi} from "vitest";
+import {ShapeX, EventListener} from "./shapex.ts";
describe("subscribe", () => {
it("subscribes to an event", () => {
- const $ = ShapeX({ counter: 1 });
- const id = $.subscribe("test-event", (state) => ({ state }));
+ const $ = ShapeX({counter: 1});
+ const id = $.subscribe("test-event", () => {
+ });
expect(id).toBe(1);
expect($.subscriptionCount("test-event")).toBe(1);
});
it("subscribes to an event once", () => {
- const $ = ShapeX({ counter: 1 });
- const id = $.subscribeOnce("test-event", (state) => ({ state }));
+ const $ = ShapeX({counter: 1});
+ const id = $.subscribeOnce("test-event", () => {
+ });
expect(id).toBe(1);
expect($.subscriptionCount("test-event")).toBe(1);
});
it("unsubscribes from an event", () => {
- const $ = ShapeX({ counter: 1 });
+ const $ = ShapeX({counter: 1});
- $.subscribe("test-event", (state) => ({ state }));
+ $.subscribe("test-event", () => {
+ });
expect($.subscriptionCount("test-event")).toBe(1);
$.unsubscribe("test-event");
@@ -31,29 +34,28 @@ describe("subscribe", () => {
describe("subscribe: async", () => {
it("subscribes to an event", () => {
- const $ = ShapeX({ counter: 1 });
- const id = $.subscribe("test-event", async (state) =>
- Promise.resolve({ state }),
- );
+ const $ = ShapeX({counter: 1});
+ const id = $.subscribe("test-event", async () => {
+ });
expect(id).toBe(1);
expect($.subscriptionCount("test-event")).toBe(1);
});
it("subscribes to an event once", () => {
- const $ = ShapeX({ counter: 1 });
- const id = $.subscribeOnce("test-event", async (state) =>
- Promise.resolve({ state }),
- );
+ const $ = ShapeX({counter: 1});
+ const id = $.subscribeOnce("test-event", async () => {
+ });
expect(id).toBe(1);
expect($.subscriptionCount("test-event")).toBe(1);
});
it("unsubscribes from an event", () => {
- const $ = ShapeX({ counter: 1 });
+ const $ = ShapeX({counter: 1});
- $.subscribe("test-event", async (state) => Promise.resolve({ state }));
+ $.subscribe("test-event", async () => {
+ });
expect($.subscriptionCount("test-event")).toBe(1);
$.unsubscribe("test-event");
@@ -67,14 +69,14 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("test-event", spyCb);
$.dispatch("test-event");
- expect(spyCb).toHaveBeenCalledWith({ counter: 1 });
+ expect(spyCb).toHaveBeenCalledWith({counter: 1});
});
it("dispatches an event with arguments", () => {
@@ -82,18 +84,14 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
-
- const testEventCb: EventCallback<AppState, string> = (state, data) => ({
- state,
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
});
- const callback = vi.fn(testEventCb);
-
- $.subscribe("test-event", callback);
+ $.subscribe("test-event", spyCb);
$.dispatch("test-event", "arg1-value");
- expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value");
+ expect(spyCb).toHaveBeenCalledWith({counter: 1}, "arg1-value");
});
it("updates state when event handler returns new state", () => {
@@ -101,19 +99,18 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.counter", spyCb);
-
- $.subscribe("increment", (state) => ({
- state: { ...state, counter: state.counter + 1 },
- }));
+ $.subscribe("increment", (state) => {
+ $.setState({...state, counter: state.counter + 1})
+ });
$.dispatch("increment");
- expect(spyCb).toHaveBeenCalledWith({ counter: 2 });
+ expect(spyCb).toHaveBeenCalledWith({counter: 2});
});
it("dispatches nested events", () => {
@@ -121,16 +118,15 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX({ counter: 1 });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("nested-event", spyCb);
- $.subscribe("parent-event", (state) => ({
- state,
- dispatch: { to: "nested-event" },
- }));
+ $.subscribe("parent-event", () => {
+ $.dispatch("nested-event")
+ });
$.dispatch("parent-event");
@@ -142,8 +138,9 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX({ counter: 1 });
- const cb: EventCallback<AppState> = (state) => ({ state });
+ const $ = ShapeX({counter: 1});
+ const cb: EventListener<AppState> = () => {
+ };
const spyCb = vi.fn(cb);
const spyCb2 = vi.fn(cb);
@@ -151,15 +148,10 @@ describe("dispatch", () => {
$.subscribe("nested-event-2", spyCb2);
- $.subscribe("parent-event", (state) => ({
- state,
- dispatch: [
- { to: "nested-event-1" },
- {
- to: "nested-event-2",
- },
- ],
- }));
+ $.subscribe("parent-event", () => {
+ $.dispatch("nested-event-1");
+ $.dispatch("nested-event-2");
+ });
$.dispatch("parent-event");
@@ -172,20 +164,19 @@ describe("dispatch", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
- const cb: EventCallback<AppState, string> = (state, arg) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("nested-event", spyCb);
- $.subscribe("parent-event", (state) => ({
- state,
- dispatch: { to: "nested-event", with: "arg-value" },
- }));
+ $.subscribe("parent-event", () => {
+ $.dispatch("nested-event", "arg-value");
+ });
$.dispatch("parent-event");
- expect(spyCb).toHaveBeenCalledWith({ counter: 1 }, "arg-value");
+ expect(spyCb).toHaveBeenCalledWith({counter: 1}, "arg-value");
});
it("supports different data types for event callback and dispatch", () => {
@@ -193,50 +184,47 @@ describe("dispatch", () => {
counter: number;
};
- type ParentEventData = {
+ type ParentEventPayload = {
id: number;
};
- type ChildEventData = {
+ type ChildEventPayload = {
message: string;
};
- const $ = ShapeX<AppState>({ counter: 1 });
+ const $ = ShapeX<AppState>({counter: 1});
- // This callback receives ChildEventData
- const childEventCb: EventCallback<AppState, ChildEventData> = (
+ // This callback receives ChildEventPayload
+ const childEventCb: EventListener<AppState, ChildEventPayload> = (
state,
- data,
- ) => ({
- state: data ? { ...state, counter: data.message.length } : state,
- });
+ payload,
+ ) => {
+ if (payload) {
+ $.setState({...state, counter: payload.message.length});
+ }
+ };
const spyChildCb = vi.fn(childEventCb);
$.subscribe("child-event", spyChildCb);
- // This callback receives ParentEventData but dispatches ChildEventData
- const parentEventCb: EventCallback<
+ // This callback receives ParentEventPayload
+ const parentEventCb: EventListener<
AppState,
- ParentEventData,
- ChildEventData
- > = (state, data) => ({
- state,
- dispatch: {
- to: "child-event",
- with: { message: `ID ${data?.id ?? 0} processed` },
- },
- });
+ ParentEventPayload
+ > = (_state, payload) => {
+ $.dispatch("child-event", {message: `ID ${payload?.id ?? 0} processed`});
+ };
$.subscribe("parent-event", parentEventCb);
// Dispatch with parent event data
- $.dispatch("parent-event", { id: 123 });
+ $.dispatch("parent-event", {id: 123});
// Child event should be called with the child event data
expect(spyChildCb).toHaveBeenCalledWith(
- { counter: 1 },
- { message: "ID 123 processed" },
+ {counter: 1},
+ {message: "ID 123 processed"},
);
// State should be updated based on the message length
@@ -250,15 +238,14 @@ describe("dispatch: async", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
- const cb: EventCallback<AppState> = async (state) =>
- Promise.resolve({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(async () => {
+ });
$.subscribe("test-event", spyCb);
$.dispatch("test-event");
- expect(spyCb).toHaveBeenCalledWith({ counter: 1 });
+ expect(spyCb).toHaveBeenCalledWith({counter: 1});
});
it("dispatches an event with arguments", () => {
@@ -266,118 +253,14 @@ describe("dispatch: async", () => {
counter: number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
-
- const testEventCb: EventCallback<AppState, string> = async (state, _) =>
- Promise.resolve({
- state,
- });
-
- const callback = vi.fn(testEventCb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const callback = vi.fn(() => {
+ });
$.subscribe("test-event", callback);
$.dispatch("test-event", "arg1-value");
- expect(callback).toHaveBeenCalledWith({ counter: 1 }, "arg1-value");
- });
-
- it("updates state when event handler returns new state", async () => {
- type AppState = {
- counter: number;
- };
-
- const $ = ShapeX<AppState>({ counter: 1 });
-
- const state = await vi.waitFor(
- () => {
- return new Promise((resolve) => {
- $.subscribe("$.counter", (state) => {
- resolve(state);
- return { state };
- });
-
- $.subscribe("increment", async (state) =>
- Promise.resolve({
- state: { ...state, counter: state.counter + 1 },
- }),
- );
-
- $.dispatch("increment");
- });
- },
- {
- timeout: 1000,
- interval: 100,
- },
- );
-
- expect(state).toStrictEqual({ counter: 2 });
- });
-
- it("dispatches nested events", async () => {
- type AppState = {
- counter: number;
- };
-
- const $ = ShapeX({ counter: 1 });
-
- const state = await vi.waitFor(() => {
- return new Promise((resolve) => {
- $.subscribe("nested-event", (state) => {
- resolve(true);
- return { state };
- });
-
- $.subscribe("parent-event", (state) => ({
- state,
- dispatch: { to: "nested-event" },
- }));
-
- $.dispatch("parent-event");
- });
- });
-
- expect(state).toBe(true);
- });
-
- it("dispatches multiple nested events", async () => {
- type AppState = {
- counter: number;
- };
-
- const $ = ShapeX({ counter: 1 });
-
- const state = await vi.waitFor(() => {
- return new Promise((resolve) => {
- let count = 0;
-
- $.subscribe("nested-event-1", (state) => {
- count++;
- return { state };
- });
-
- $.subscribe("nested-event-2", (state) => {
- resolve(count + 1);
- return { state };
- });
-
- $.subscribe("parent-event", async (state) =>
- Promise.resolve({
- state,
- dispatch: [
- { to: "nested-event-1" },
- {
- to: "nested-event-2",
- },
- ],
- }),
- );
-
- $.dispatch("parent-event");
- });
- });
-
- expect(state).toBe(2);
+ expect(callback).toHaveBeenCalledWith({counter: 1}, "arg1-value");
});
});
@@ -390,21 +273,21 @@ describe("state change detection", () => {
};
};
- const $ = ShapeX<AppState>({ counter: 1, nested: { value: "test" } });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1, nested: {value: "test"}});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.counter", spyCb);
- $.subscribe("change-counter", (state) => ({
- state: { ...state, counter: 2 },
- }));
+ $.subscribe("change-counter", (state) => {
+ $.setState({...state, counter: 2});
+ });
$.dispatch("change-counter");
expect(spyCb).toHaveBeenCalledWith({
counter: 2,
- nested: { value: "test" },
+ nested: {value: "test"},
});
});
@@ -416,32 +299,26 @@ describe("state change detection", () => {
};
};
- const $ = ShapeX<AppState>({ counter: 1, nested: { value: "test" } });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1, nested: {value: "test"}});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.nested.value", spyCb);
- $.subscribe("change-nested-value", (state) => ({
- state: {
- ...state,
- nested: { ...state.nested, value: "new value" },
- },
- }));
+ $.subscribe("change-nested-value", (state) => {
+ $.setState({...state, nested: {...state.nested, value: "new value"}});
+ });
- $.subscribe("change-nested-value-again", (state) => ({
- state: {
- ...state,
- nested: { ...state.nested, value: "new value again" },
- },
- }));
+ $.subscribe("change-nested-value-again", (state) => {
+ $.setState({...state, nested: {...state.nested, value: "new value again"}});
+ });
$.dispatch("change-nested-value");
$.dispatch("change-nested-value-again");
expect(spyCb).toHaveBeenCalledWith({
counter: 1,
- nested: { value: "new value" },
+ nested: {value: "new value"},
});
});
@@ -451,18 +328,13 @@ describe("state change detection", () => {
};
const $ = ShapeX<AppState>({});
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.view", spyCb);
$.subscribe("set-view", (state) => {
- return {
- state: {
- ...state,
- view: "test",
- },
- };
+ $.setState({...state, view: "test",})
});
$.dispatch("set-view");
@@ -478,7 +350,8 @@ describe("state change detection", () => {
};
const $ = ShapeX<AppState>({});
- const cb: EventCallback<AppState> = (state) => ({ state });
+ const cb: EventListener<AppState> = () => {
+ };
const spyCb = vi.fn(cb);
const spyCb2 = vi.fn(cb);
@@ -486,21 +359,21 @@ describe("state change detection", () => {
$.subscribe("$.nested.value", spyCb2);
$.subscribe("set-nested-value", (state) => {
- return {
- state: {
- ...state,
- nested: { value: "test" },
- },
- };
+ $.setState({
+ ...state,
+ nested: {
+ value: "test"
+ }
+ })
});
$.subscribe("set-nested-value-again", (state) => {
- return {
- state: {
- ...state,
- nested: { value: "test-again" },
- },
- };
+ $.setState({
+ ...state,
+ nested: {
+ value: "test-again"
+ }
+ })
});
$.dispatch("set-nested-value");
@@ -516,14 +389,15 @@ describe("state change detection", () => {
toDelete?: string;
};
- const $ = ShapeX<AppState>({ counter: 1, toDelete: "value" });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1, toDelete: "value"});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.toDelete", spyCb);
$.subscribe("delete-property", (state) => {
- const newState = { counter: state.counter };
- return { state: newState };
+ $.setState({
+ counter: state.counter
+ })
});
$.dispatch("delete-property");
@@ -536,14 +410,17 @@ describe("state change detection", () => {
counter: string | number;
};
- const $ = ShapeX<AppState>({ counter: 1 });
- const cb: EventCallback<AppState> = (state) => ({ state });
- const spyCb = vi.fn(cb);
+ const $ = ShapeX<AppState>({counter: 1});
+ const spyCb = vi.fn(() => {
+ });
$.subscribe("$.counter", spyCb);
- $.subscribe("change-counter-type", (state) => ({
- state: { ...state, counter: "string now" },
- }));
+ $.subscribe("change-counter-type", (state) => {
+ $.setState({
+ ...state,
+ counter: "string now"
+ })
+ });
$.dispatch("change-counter-type");
@@ -553,10 +430,12 @@ describe("state change detection", () => {
describe("utility methods", () => {
it("returns all subscription names", () => {
- const $ = ShapeX({ counter: 1 });
+ const $ = ShapeX({counter: 1});
- $.subscribe("event1", (state) => ({ state }));
- $.subscribe("event2", (state) => ({ state }));
+ $.subscribe("event1", () => {
+ });
+ $.subscribe("event2", () => {
+ });
const subs = $.subscriptions();
@@ -566,22 +445,27 @@ describe("utility methods", () => {
});
it("returns subscription count for specific event", () => {
- const $ = ShapeX({ counter: 1 });
+ const $ = ShapeX({counter: 1});
- $.subscribe("event1", (state) => ({ state }));
- $.subscribe("event1", (state) => ({ state }));
- $.subscribe("event2", (state) => ({ state }));
+ $.subscribe("event1", () => {
+ });
+ $.subscribe("event1", () => {
+ });
+ $.subscribe("event2", () => {
+ });
expect($.subscriptionCount("event1")).toBe(2);
expect($.subscriptionCount("event2")).toBe(1);
});
it("returns updated state", () => {
- const $ = ShapeX({ counter: 1 });
+ const $ = ShapeX({counter: 1});
- $.subscribe("event1", (state) => ({
- state: { counter: state.counter + 1 },
- }));
+ $.subscribe("event1", (state) => {
+ $.setState({
+ counter: state.counter + 1
+ })
+ });
$.dispatch("event1");
diff --git a/src/shapex.ts b/src/shapex.ts
index 3ff67a8..fa9047f 100644
--- a/src/shapex.ts
+++ b/src/shapex.ts
@@ -1,78 +1,55 @@
/**
- * Dispatches an event with a given name and passes on
- * given arguments to it.
- */
-export type SubscriptionResponseDispatch<W extends unknown = undefined> = {
- to: string;
- with?: W;
-};
-
-/**
- * A response of the subscription callback. Should return new state
- * if you want to update state, and/or optionally also any events you
- * might want to dispatch.
- */
-export type SubscriptionResponse<T, D extends unknown = undefined> = {
- state?: T;
- dispatch?:
- | SubscriptionResponseDispatch<D>
- | SubscriptionResponseDispatch<D>[];
-};
-
-const isSubscriptionResponseList = <W extends unknown = undefined>(
- dispatch: SubscriptionResponseDispatch<W> | SubscriptionResponseDispatch<W>[],
-): dispatch is SubscriptionResponseDispatch<W>[] => Array.isArray(dispatch);
-
-/**
* A callback passed to subscriptions, called when the event
* that the subscription is listening to is called.
*/
-export type EventCallback<
- T,
- W extends unknown = undefined,
- D extends unknown = undefined,
+export type EventListener<
+ TState,
+ TPayload extends unknown = undefined,
> = (
- state: T,
- data?: W,
-) =>
- | SubscriptionResponse<T, D>
- | Promise<SubscriptionResponse<T, D>>
- | void
- | Promise<void>;
+ state: TState,
+ payload?: TPayload,
+) => void | Promise<void>;
type Subscription<
- T,
- W extends unknown = undefined,
- D extends unknown = undefined,
+ TState,
+ TPayload extends unknown = undefined,
> = {
- listener: string;
- callback: EventCallback<T, W, D>;
+ callback: EventListener<TState, TPayload>;
once: boolean;
};
/**
* An instance of the ShapeX object.
*/
-export type ShapeXInstance<T> = {
+export type ShapeXInstance<TState> = {
/**
* Subscribe to an event.
+ *
+ * @param topic
+ * @param callback
*/
- subscribe: <W extends unknown = undefined, D extends unknown = undefined>(
- listener: string,
- callback: EventCallback<T, W, D>,
+ subscribe: <TPayload extends unknown = undefined>(
+ topic: string,
+ callback: EventListener<TState, TPayload>,
) => number;
+
/**
* Subscribe to an event once.
+ *
+ * @param topic
+ * @param callback
*/
- subscribeOnce: <W extends unknown = undefined, D extends unknown = undefined>(
- listener: string,
- callback: EventCallback<T, W, D>,
+ subscribeOnce: <TPayload extends unknown = undefined>(
+ topic: string,
+ callback: EventListener<TState, TPayload>,
) => number;
/**
* Unsubscribe from an event.
+ *
+ * @param topic
*/
- unsubscribe: (listener: string) => void;
+ unsubscribe: (topic: string) => void;
/**
* Get the number of subscriptions for an event.
@@ -86,102 +63,109 @@ export type ShapeXInstance<T> = {
/**
* Dispatch an event.
+ *
+ * @param to
+ * @param payload
*/
- dispatch: <W extends unknown>(to: string, withData?: W) => void;
+ dispatch: <TPayload extends unknown>(to: string, payload?: TPayload) => void;
/**
* Get the current state.
*/
- state: () => T;
+ state: () => TState;
+
+ /**
+ * Update state.
+ *
+ * @param updatedState
+ */
+ setState: (updatedState: TState) => void;
};
/**
* A function that creates an EventX object.
*
- * @param {T extends object} initialState The initial application state.
+ * @param {TState extends object} initialState The initial application state.
* @returns {ShapeXInstance} The ShapeX object.
*/
-export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> {
+export function ShapeX<TState extends object>(initialState: TState): ShapeXInstance<TState> {
let _state = initialState;
- const _subscriptions: Map<
- string,
- Array<Subscription<T, unknown, unknown>>
- > = new Map();
- let subscriptionId = 0;
+ let _subscriptionId = 0;
+ const _subscriptions: Map<string, Array<Subscription<TState, unknown>>> = new Map();
/**
* Subscribe to an event.
*
- * @param {string} listener
- * @param {EventCallback} callback
+ * @param topic
+ * @param {EventListener} callback
* @returns
*/
const subscribe = <
- W extends unknown = undefined,
- D extends unknown = undefined,
+ TPayload extends unknown = undefined,
>(
- listener: string,
- callback: EventCallback<T, W, D>,
+ topic: string,
+ callback: EventListener<TState, TPayload>,
): number => {
- if (!_subscriptions.has(listener)) {
- _subscriptions.set(listener, []);
+ // there are no listeners, set as empty array
+ if (!_subscriptions.has(topic)) {
+ _subscriptions.set(topic, []);
}
- const subscriptions = _subscriptions.get(listener);
- if (subscriptions) {
- subscriptions.push({
- listener,
- callback: callback as unknown as EventCallback<T, unknown, unknown>,
- once: false,
- });
- }
+ // add a listener
+ _subscriptions.get(topic)?.push({
+ callback: callback as unknown as EventListener<TState, unknown>,
+ once: false,
+ });
- return ++subscriptionId;
+ return ++_subscriptionId;
};
/**
* Subscribe to an event, once.
*
- * @param {string} listener
- * @param {EventCallback} callback
+ * @param {string} topic
+ * @param {EventListener} callback
* @returns
*/
- const subscribeOnce = <W extends unknown = undefined, D extends unknown = W>(
- listener: string,
- callback: EventCallback<T, W, D>,
+ const subscribeOnce = <TPayload extends unknown = undefined>(
+ topic: string,
+ callback: EventListener<TState, TPayload>,
): number => {
- if (!_subscriptions.has(listener)) {
- _subscriptions.set(listener, []);
+ // there are no listeners, set as empty array
+ if (!_subscriptions.has(topic)) {
+ _subscriptions.set(topic, []);
}
- const subscriptions = _subscriptions.get(listener);
- if (subscriptions) {
- subscriptions.push({
- listener,
- callback: callback as unknown as EventCallback<T, unknown, unknown>,
- once: true,
- });
- }
+ // add a listener
+ _subscriptions.get(topic)?.push({
+ callback: callback as unknown as EventListener<TState, unknown>,
+ once: true,
+ });
- return ++subscriptionId;
+ return ++_subscriptionId;
};
- const unsubscribe = (listener: string): void => {
- if (_subscriptions.has(listener)) {
- _subscriptions.delete(listener);
+ /**
+ * Removes all listeners for a topic.
+ *
+ * @param topic
+ */
+ const unsubscribe = (topic: string): void => {
+ if (_subscriptions.has(topic)) {
+ _subscriptions.delete(topic);
}
};
/**
* Composes a list of changes between two states.
*
- * @param {T extends object} oldState
- * @param {T extends object} newState
+ * @param {TState extends object} oldState
+ * @param {TState extends object} newState
* @returns {string[]} The list of changes as array of paths.
*/
- const changedState = <T extends object>(
- oldState: T,
- newState: T,
+ const changedState = <TState extends object>(
+ oldState: TState,
+ newState: TState,
): string[] => {
const paths = <R extends object>(
state: R,
@@ -210,16 +194,16 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> {
const newPaths = paths(newState, "$");
const newPathKeys = newPaths.map((x) => x.path);
- // All new paths
+ // all new paths
const added = newPathKeys.filter((path) => !oldPathKeys.includes(path));
- // All removed paths
+ // all removed paths
const removed = oldPathKeys.filter((path) => !newPathKeys.includes(path));
- // Paths that remained
+ // paths that remained
const same = oldPathKeys.filter((path) => newPathKeys.includes(path));
- // Paths that changed
+ // paths that changed
const changed = same.filter((path) => {
const oldValue = oldPaths.find((x) => x.path === path)?.value;
const newValue = newPaths.find((x) => x.path === path)?.value;
@@ -233,94 +217,33 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> {
return differ(oldState, newState);
};
- const dispatcher = (
- response: SubscriptionResponse<T, unknown>,
- subscription: Subscription<T, unknown, unknown>,
- callbackCount: number,
- remainingSubscriptions: Subscription<T, unknown, unknown>[],
- ) => {
- // Updates state, and checks for state changes, and if any changes present,
- // fires a dispatch for all the state listeners (if there are any).
- if (response?.state !== undefined) {
- const changes = changedState(_state, response.state);
- _state = response.state;
-
- for (let i = 0; i < changes.length; i++) {
- dispatch(changes[i]);
- }
- }
-
- // Dispatches events
- if (response?.dispatch !== undefined) {
- if (isSubscriptionResponseList(response.dispatch)) {
- for (const dispatchee of response.dispatch) {
- if (dispatchee?.with) {
- dispatch(dispatchee.to, dispatchee.with);
- } else {
- dispatch(dispatchee.to);
- }
- }
- } else {
- if (response.dispatch?.with) {
- dispatch(response.dispatch.to, response.dispatch.with);
- } else {
- dispatch(response.dispatch.to);
- }
- }
- }
-
- callbackCount++;
-
- if (!subscription.once) {
- remainingSubscriptions.push(subscription);
- }
- };
-
/**
* Dispatches an event with the given name and arguments.
*
* @param {string} to The name of the event to dispatch.
- * @param {unknown[]} withData The arguments to pass to the event listeners.
+ * @param payload
* @returns {void}
*/
- const dispatch = <W extends unknown = undefined>(
+ const dispatch = <TPayload extends unknown = undefined>(
to: string,
- withData?: W,
+ payload?: TPayload,
): void => {
if (!_subscriptions.has(to)) return;
const scopedSubscriptions = _subscriptions.get(to) ?? [];
- const remainingSubscriptions = [] as Array<Subscription<T, unknown, unknown>>;
- let callbackCount = 0;
+ const remainingSubscriptions = [] as Array<Subscription<TState, unknown>>;
for (const subscription of scopedSubscriptions) {
- const callback = subscription.callback as unknown as EventCallback<T, W, unknown>;
- let response = withData ? callback(_state, withData) : callback(_state);
-
- // Async response
- if (response instanceof Promise) {
- response.then((result) => {
- if (!result) return;
-
- dispatcher(
- result,
- subscription,
- callbackCount,
- remainingSubscriptions,
- );
- });
- }
+ const callback = subscription.callback as unknown as EventListener<TState, TPayload>;
- // Sync response
- else {
- if (!response) return;
+ if (payload) {
+ callback(_state, payload);
+ } else {
+ callback(_state);
+ }
- dispatcher(
- response,
- subscription,
- callbackCount,
- remainingSubscriptions,
- );
+ if (!subscription.once) {
+ remainingSubscriptions.push(subscription);
}
}
@@ -355,10 +278,24 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> {
*
* @returns {} The current state.
*/
- const state = (): T => {
+ const state = (): TState => {
return _state;
};
+ /**
+ * Updates state.
+ *
+ * @param updatedState updated state
+ */
+ const setState = (updatedState: TState): void => {
+ const changes = changedState(_state, updatedState);
+ _state = updatedState;
+
+ for (let i = 0; i < changes.length; i++) {
+ dispatch(changes[i]);
+ }
+ }
+
return {
subscribe,
subscribeOnce,
@@ -367,5 +304,6 @@ export function ShapeX<T extends object>(initialState: T): ShapeXInstance<T> {
subscriptions,
dispatch,
state,
+ setState,
};
}