Skip to content

Commit

Permalink
feat: throws errors when arguments are invalid or state type is not a…
Browse files Browse the repository at this point in the history
… plain object
  • Loading branch information
geotrev committed Apr 16, 2023
1 parent d528168 commit 0c14e91
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 95 deletions.
60 changes: 32 additions & 28 deletions README.md
Expand Up @@ -60,7 +60,9 @@ The CDN puts the library on `window.CoreFlux`.

The one and only export of Core Flux. Use it to create a store instance. You can create as few or as many stores as your heart desires! They will all be independent from one another.

The function **requires** all four of its arguments, as shown here:
_**NOTE**: The base state type must be a plain object. Invalid types will throw an error._

The function **requires** all four of its arguments, including [bindings](#bindings):

```js
// foo-store.js
Expand All @@ -85,28 +87,6 @@ export { subscribe, dispatch }

Once a store is created, you'll be able to add subscriptions with `subscribe` and request state updates with `dispatch`.

#### Bindings

Here's a breakdown of each binding needed when initializing a new store:

**`reducer(state, action)`**

> `state (object)`: A _copy_ of the current state object.<br/>`action ({ type: string, payload: object })`: The dispatched action type and its payload.
Creates a new version of state and returns it, based on the `type` and `payload`. If the return value is falsy, the update process ends.

**`bindSubscriber(newSubscription, state)`**

> `newSubscription ([subscriber, data])`: A tuple containing the subscribed object and its state-relational data.<br/>`state (object)`: A _copy_ of the current state object.
Called after a new `subscribe` call is made and a subscription has been added to the store. Use it to set initial state on the new subscriber based on the `data` defined by your subscriber.

**`bindState(subscriptions, reducedState, setState)`**

> `subscriptions (subscription[])`: An array containing all subscriptions.<br/>`reducedState (object)`: The state object as returned by the reducer.<br/>`setState (function)`:
Called after the reducer has processed the next state value. Use it to set the reduced state back to subscribers **and** back to the store.

<h3 id="subscribe"><code>subscribe(subscriber, data)</code></h3>

Adds a subscription to your store. It will always be tied to a single store, and subsequently state object.
Expand All @@ -129,7 +109,7 @@ In the above example, we've designed the subscriber, the `FooItems` class, to de

After the subscribe call is made, your `bindSubscriber` function will be called where you can pass along the default values as you see fit.

> In general, you should try to use a simple data structure as the second argument to `subscribe`; this ensures your bindings have generic and consistent expectations.
_**NOTE:** In general, you should try to use a simple data structure as the second argument to `subscribe`; this ensures your bindings have generic and consistent expectations._

<h3 id="dispatch"><code>dispatch(type, payload)</code></h3>

Expand Down Expand Up @@ -164,11 +144,33 @@ The reducer could have a logic branch on the action type called `ADD_ITEM` which

Finally, the result would then be handed over to your `bindState` binding.

> Much like in `subscribe`, it's best to maintain data types in the payload so your reducer can have consistent expectations.
_**NOTE:** Much like in `subscribe`, it's best to maintain data types in the payload so your reducer can have consistent expectations._

#### Bindings

Here's a breakdown of each binding needed when initializing a new store:

##### **`bindSubscriber(subscription, state)`**

> `subscription ([subscriber, data])`: A tuple containing the subscribed object and its state-relational data.<br/>`state (object)`: The current state object.
Called after a new `subscribe` is made and a subscription has been added to the store. Use it to _set initial state_ on the new subscriber. Use the `data` provided to infer a new operation, e.g., setting a stateful property to the subscriber.

##### **`reducer(state, action)`**

> `state (object)`: Snapshot of the current state object.<br/>`action ({ type: string, payload: object })`: The dispatched action type and its payload.
Called during a new `dispatch`. Create a new version of state and return it.

##### **`bindState(subscriptions, reducedState, setState)`**

> `subscriptions (subscription[])`: An array containing all subscriptions.<br/>`reducedState (object)`: The state object as returned by the reducer.<br/>`setState (function)`:
Called at the end of a `dispatch` call, after your reducer callback has processed the next state value. Set your new state back to subscribers **and** back to the store. It's possible and expected for you to call `bindSubscriber` again to DRYly apply these updates. You can return from this function safely to noop.

## Exposing the store

For utility or debugging reasons, you may want to look at the store you're working with. To do so, you can use the `__data` property when creating a store.
For utility or debugging reasons, you may want to look at the store you're working with. To do so, you can use the `__data` property when creating a store:

```js
const fooStore = createStore(initialState, reducer, bindSubscriber, bindState)
Expand All @@ -178,9 +180,11 @@ window.fooStoreData = fooStore.__data
console.log(window.fooStoreData) // { state: {...}, subscriptions: [...] }
```

_**NOTE:** Avoid including `__data` in production environments; the data is mutable and therefore exposes a security risk if accessible._

## Data model

Core Flux has a relatively simple data model that you should understand when creating your bindings.
Core Flux has a relatively simple data model that you should understand when creating [bindings](#bindings).

Here is how state looks in all cases:

Expand All @@ -198,7 +202,7 @@ Store {

Each item in `subscriptions` contains a `subscriber` and some form of `data` that informs a relationship between `state` and `subscriber`.

NOTE: You define `data` in the above model, be it an object, array, string; it can be anything you want. Ultimately, you're responsible for communicating state relationships to subscribers.
_**NOTE:** \_You_ define `data` in the above model. This ensures that ultimately you control communicating state relationships to subscribers.\_

## Data flow

Expand Down
80 changes: 72 additions & 8 deletions __tests__/core-flux.spec.js
Expand Up @@ -6,11 +6,18 @@ const mockReducer = jest.fn()

const testSubscriberData = "variable test data"
const testSubscriber = {}
const TEST_TYPE = "test"
const FAIL_TYPE = "fail"

function testReducer(state, action) {
if (action.type === "TEST_TYPE") {
state.foo = action.payload.foo
return state
switch (action.type) {
case TEST_TYPE: {
state.foo = action.payload.foo
return state
}
case FAIL_TYPE: {
return action.payload
}
}
}

Expand All @@ -23,7 +30,7 @@ function getMockStore() {
}

describe("createStore", () => {
describe("artifacts", () => {
describe("initialize", () => {
it("returns dispatch and subscribe helper functions", () => {
// Given
const Store = getMockStore()
Expand All @@ -43,10 +50,24 @@ describe("createStore", () => {
expect.objectContaining({ state: {}, subscriptions: [] })
)
})

it("throws error if invalid initialState", () => {
// Given
const badStates = [false, null, undefined, [], 123, "foo"]

badStates.forEach((badState) => {
// When
const createBadStore = () => createStore(badState, mockReducer)

// Then
expect(createBadStore).toThrow(
"[core-flux] createStore(): The initial state value must be a plain object."
)
})
})
})

describe("state bindings", () => {
const TEST_TYPE = "TEST_TYPE"
const testPayload = { foo: "bar" }

it("calls reducer on dispatch", () => {
Expand All @@ -57,7 +78,7 @@ describe("createStore", () => {
Store.dispatch(TEST_TYPE, testPayload)

// Then
expect(mockReducer).toBeCalledWith(
expect(mockReducer).toHaveBeenCalledWith(
Store.__data.state,
expect.objectContaining({ payload: testPayload, type: TEST_TYPE })
)
Expand All @@ -77,7 +98,7 @@ describe("createStore", () => {
Store.dispatch(TEST_TYPE, testPayload)

// Then
expect(mockBindState).toBeCalledWith(
expect(mockBindState).toHaveBeenCalledWith(
Store.__data.subscriptions,
expect.objectContaining({ foo: "bar" }),
expect.any(Function)
Expand All @@ -101,6 +122,27 @@ describe("createStore", () => {
expect.objectContaining({ foo: "bar" })
)
})

it("throws error if next state value is not a plain object", () => {
// Given
const Store = createStore(
{},
testReducer,
mockBindSubscriber,
testBindState
)
const badStates = [false, null, undefined, [], 123, "foo"]

badStates.forEach((badState) => {
// When
const dispatchBadState = () => Store.dispatch(FAIL_TYPE, badState)

// Then
expect(dispatchBadState).toThrow(
"[core-flux] bindState callback: The reduced state value must be a plain object. If there is no change in state, simply return it."
)
})
})
})

describe("subscriber bindings", () => {
Expand All @@ -112,7 +154,7 @@ describe("createStore", () => {
Store.subscribe(testSubscriber, testSubscriberData)

// Then
expect(mockBindSubscriber).toBeCalledWith(
expect(mockBindSubscriber).toHaveBeenCalledWith(
Store.__data.subscriptions[0],
Store.__data.state
)
Expand All @@ -132,5 +174,27 @@ describe("createStore", () => {
])
)
})

it("throws error if invalid subscriber", () => {
// Given
const Store = getMockStore()
const subscribe = () => Store.subscribe(null, testSubscriberData)

// Then
expect(subscribe).toThrow(
"[core-flux] subscribe(): `subscriber` and `data` arguments are required."
)
})

it("throws error if invalid subscriber data", () => {
// Given
const Store = getMockStore()
const subscribe = () => Store.subscribe(testSubscriber, null)

// Then
expect(subscribe).toThrow(
"[core-flux] subscribe(): `subscriber` and `data` arguments are required."
)
})
})
})

0 comments on commit 0c14e91

Please sign in to comment.