diff --git a/README.md b/README.md index 5a0becb..59b6473 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Differences with [freactal](https://github.com/FormidableLabs/freactal/): - computed values are available in the `state` in effects - `finalize` effect is triggered on unmount (symmetry with `initialize`) - easy async effects +- no state and effects injection (replace with [React context](https://reactjs.org/docs/context.html)) - async computed support - update state by default: no need for `Object.assign()` and `{...state}` - no [helper functions](https://github.com/FormidableLabs/freactal#helper-functions) (not necessary) @@ -29,46 +30,69 @@ Installation of the [npm package](https://npmjs.org/package/reaclette): ```js import React from "react"; import { render } from "react-dom"; -import { injectState, provideState } from "reaclette"; - -const wrapComponentWithState = provideState({ - initialState: () => ({ counter: 0 }), - effects: { - addOne: () => (state, props) => ({ counter: state.counter + 1 }), - }, - computed: { - square: (state, props) => state.counter * state.counter, +import { withStore } from "reaclette"; + +const Component = withStore( + { + initialState: props => ({ counter: 0 }), + effects: { + addOne() { + this.state.counter += 1; + }, + }, + computed: { + square: (state, props) => state.counter * state.counter, + }, }, -}); - -const Parent = wrapComponentWithState(() => ); - -const Child = injectState(({ effects, state }) => ( -
-

Our counter is at: {state.counter}

-

Its squared value is: {state.square}

-

- -

-
-)); + (store, props) => ( +
+

Our counter is at: {store.state.counter}

+

Its squared value is: {store.state.square}

+

+ +

+
+ ) +); -render(, document.body); +render(, document.body); ``` ## API ```js -import { provideState, injectState } from "reaclette"; +import { withStore } from "reaclette"; ``` -### `provideState(options) => (Component => Component)` +### `withStore(options, renderFunction)` Create a decorator that associates a React component with a store. -`options` is an object which can contain the following properties. +`renderFunction` + +A function that receives `store` and `props` in parameters and returns a `React node` like so: + +```js +(store, props) => ReactNode; +``` + +- `store` + Is an object containing the following properties: + + - `state` + - [`effects`](#effects) + - [`resetState`](#reset-state) + +- `props` + Passed inputs to React node + +`options` -#### `initialState(props) => object` +Is an object which can contain the following properties: + +#### Initial state + +`initialState(props) => object` This function returns the initial state of store, which can be computed from the properties of the decorated component. @@ -78,53 +102,86 @@ This function returns the initial state of store, which can be computed from the } ``` -#### `effects: { [string]: Effect }` +#### Effects -These functions can be called from application code (see `injectState`) and can do side-effects and/or mutate the state. +`effects: { [string]: Effect }` -When called, an effect is provided with one or more arguments: a reference to other effects and any arguments passed to the effect from the application code. +These functions can be called from application code and can do side-effects and/or mutate the state. -An effect can return: +When called, an effect is provided with one or more arguments: a reference to other effects and any arguments passed to the effect from the application code. -1. nothing (`undefined` or `null`) -2. an object containing new properties to merge in the state -3. a promise resolving to one of the above -4. a function which receives the state and the props then returns one of the above +Effects can access effects, state (read and write) and props (at the time the effect was called) via `this`, which is extremely +handy for async effects: ```js -{ - effects: { - incrementCounter: (effects) => (state, props) => ({ counter: state.counter + 1 }), - onInputChange: (effects, event) => ({ counter: +event.target.value }), - } -} +const Component = withStore( + { + initialState: () => ({ + data: undefined, + loading: false, + }), + effects: { + loadData: async function() { + const { state } = this; + const { effects } = this; + if (state.data !== undefined || state.loading) { + return; + } + + state.loading = true; + try { + state.data = await fetchData(); + } finally { + state.loading = false; + } + }, + }, + }, + (store, props) => ( +
+ {store.state.loading ? ( + "Loading..." + ) : ( +
{JSON.stringify(store.state.data, null, " ")}
+ )} +
+ ) +); + +render(, document.body); ``` -Effects can also access effects, state (read and write) and props (at the time the effect was called) via `this`, which is extremely -handy for async effects: +Effects are always asynchronous; therefore, they must always be awaited. ```js -provideState({ - initialState: () => ({ - data: undefined, - loading: false, - }), - effects: { - loadData: async function() { - const { state } = this; - if (state.data !== undefined || state.loading) { - return; - } - - state.loading = true; - try { - state.data = await fetchData(); - } finally { - state.loading = false; - } +const Component = withStore( + { + initialState: props => ({ counter: 0 }), + effects: { + async addOne() { + const { state } = this; + const { effects } = this; + state.counter += 1; + if (state.counter > 5) { + await effects.addBonus(); + } + }, + addBonus() { + this.state.counter += 5; + }, }, }, -}); + (store, props) => ( +
+

Our counter is at: {store.state.counter}

+

+ +

+
+ ) +); + +render(, document.body); ``` There are two special effects: @@ -134,7 +191,9 @@ There are two special effects: Note that these effects are **not called** on server side! -#### `computed: { [string]: Compute }` +#### Computed + +`computed: { [string]: Compute }` _Computeds_ are lazy values derived from the state and the properties of the decorated component. @@ -153,27 +212,30 @@ They are automatically (re-)computed when necessary. Compute functions can be async which is extremely useful to fetch data: ```js -const CitySelector = provideState({ - computed: { - // the computed is undefined in the render before the promise settles - // - // rejections are not handled and will possibly trigger an - // unhandledRejection event on the window object, the computed will stay - // undefined - async cities({ country }) { - const response = await fetch(`/countries/${state.country}/cities`); - return response.json(); +const CitySelector = withStore( + { + computed: { + // the computed is undefined in the render before the promise settles + // + // rejections are not handled and will possibly trigger an + // unhandledRejection event on the window object, the computed will stay + // undefined + async cities({ country }) { + const response = await fetch(`/countries/${state.country}/cities`); + return response.json(); + }, }, }, -})( - injectState(({ onChange, state, effects, value }) => ( - + {store.state.cities !== undefined + ? store.state.cites.map(city => ) : null} - )) + ) ); + +render(, document.body); ``` Even though computed can use state and props, they don't have to: @@ -186,11 +248,7 @@ Even though computed can use state and props, they don't have to: } ``` -### `injectState(Component) => Component` - -Makes - -#### `resetState()` +#### Reset state This function resets the state by calling `initialState` with the current properties of the decorated component. @@ -199,23 +257,29 @@ This function resets the state by calling `initialState` with the current proper This pseudo-effect is passed as a property by `injectState`: ```js -const Component = injectState({ effects, state, resetState }) => ( -
- // ... -
-) +const Component = withStore( + { + initialState: () => ({}), + effects: {}, + }, + (store, props) =>
{/* ... */}
+); ``` And also available from effects via their context: ```js -const withState = provideState({ - // ... - effects: { - async myEffect () { - await this.resetState() - } -}) +const Component = withStore( + { + // ... + effects: { + async myEffect() { + await this.resetState(); + }, + }, + }, + (store, props) => +); ``` ## Recipes @@ -227,164 +291,11 @@ const withState = provideState({ import Preact, { render } from "preact"; import factory from "reaclette/factory"; -const { injectState, provideState } = factory(Preact); +const { withStore } = factory(Preact); // The rest is the same. ``` -### Testing - -> This example comes from the excellent [Freactal documentation](https://github.com/FormidableLabs/freactal/#testing). - -`App.js`: - -```js -import React from "react"; -import { injectState, provideState } from "reaclette"; - -export default provideState({ - initialState: () => ({ - givenName: "Walter", - familyName: "Harriman", - }), - effects: { - onChangeGiven: (_, { target: { value } }) => state => ({ - givenName: value, - }), - onChangeFamily: (_, { target: { value } }) => state => ({ - familyName: value, - }), - }, - computed: { - fullName: ({ givenName, familyName }) => `${givenName} ${familyName}`, - greeting: ({ fullName }) => `Hi, ${fullName}, and welcome!`, - }, -})( - injectState(({ state, effects }) => ( -
-

{state.greeting}

-

- -

-

- -

-
- )) -); -``` - -`App.spec.js`: - -```js -import React from "react"; -import Adapter from "enzyme-adapter-react-16"; -import { configure, mount } from "enzyme"; - -configure({ adapter: new Adapter() }); - -import AppWithState from "./App.js"; - -describe("my app", () => { - // From the decorated component you can obtained the plain version - // directly. - const App = AppWithState.WrappedComponent; - - // We'll be re-using these values, so let's put it here for convenience. - const state = { - givenName: "Charlie", - familyName: "In-the-box", - fullName: "Charlie In-the-box", - greeting: "Howdy there, kid!", - }; - - it("displays a greeting to the user", () => { - // This test should be easy - all we have to do is ensure that - // the string that is passed in is displayed correctly! - - // We're not doing anything with effects here, so let's not bother - // setting them for now... - const effects = {}; - - // First, we mount the component, providing the expected state and effects. - const el = mount(); - - // And then we can make assertions on the output. - expect(el.find("#greeting").text()).toBe("Howdy there, kid!"); - }); - - it("accepts changes to the given name", () => { - // Next we're testing the conditions under which our component might - // interact with the provided effects. - const effects = { - onChangeGiven: jest.fn(), - onChangeFamily: jest.fn(), - }; - - const el = mount(); - - // We don't expect our effect to be invoked when the component - // mounts, so let's make that assertion here. - expect(effects.onChangeGiven).not.toHaveBeenCalled(); - - // Next, we can simulate a input-box value change. - el.find("input#given").simulate("change", { - target: { value: "Eric" }, - }); - - // And finally, we can assert that the effect - or, rather, the Sinon - // spy that is standing in for the effect - was invoked with the expected - // value. - expect(effects.onChangeGiven).toHaveBeenCalledWith( - expect.objectContaining({ - target: { - value: "Eric", - }, - }) - ); - }); -}); - -describe("my app state", () => { - // From the decorated component you can obtained the decorator - // providing the state directly. - const { wrapComponentWithState } = AppWithState; - - it("supports fullName", async () => { - // Normally, you'd pass a component as the first argument to your - // state template. However, if you pass no argument to the state - // template, you'll get back a test instance that you can extract - // `state` and `effects` from. Just don't try to render the thing! - const { effects, getState } = wrapComponentWithState(); - - expect(getState().fullName).toBe("Walter Harriman"); - - await effects.onChangeGiven({ target: { value: "Alfred" } }); - expect(getState().fullName).toBe("Alfred Harriman"); - - await effects.onChangeFamily({ target: { value: "Hitchcock" } }); - expect(getState().fullName).toBe("Alfred Hitchcock"); - }); - - // You could write similar assertions here - it("supports a greeting"); -}); -``` - ## Development ```