├── server.js ├── example ├── src │ ├── index.css │ ├── child-component │ │ ├── computed.js │ │ ├── initial-state.js │ │ ├── effects.js │ │ └── index.js │ ├── index.js │ ├── state │ │ ├── index.js │ │ ├── initial-state.js │ │ ├── computed.js │ │ └── effects.js │ ├── app.js │ └── server.js ├── .gitignore └── package.json ├── src ├── context.js ├── common.js ├── index.js ├── server │ ├── index.js │ ├── set-state.js │ ├── capture.js │ ├── resolve-promise-tree.js │ ├── context.js │ └── partial-render.js ├── effects.js ├── helpers.js ├── inject.js ├── state.js └── provide.js ├── spec ├── .babelrc ├── _util │ └── babel-resolve-paths-plugin.js ├── index.js ├── integration │ └── nested-state-injection.spec.js └── unit │ ├── effects.spec.js │ ├── inject.spec.js │ ├── provide.spec.js │ └── state.spec.js ├── circle.yml ├── .babelrc ├── .eslintrc ├── .gitignore ├── rollup.config.js ├── deploy.sh ├── LICENSE ├── .maintainerd ├── CONTRIBUTE.md ├── package.json ├── COC.md └── README.md /server.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./lib/server"); 2 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { object } from "prop-types"; 2 | 3 | export const contextTypes = { 4 | freactal: object 5 | }; 6 | -------------------------------------------------------------------------------- /example/src/child-component/computed.js: -------------------------------------------------------------------------------- 1 | export default { 2 | localValueLength: ({ localValue }) => localValue.length 3 | }; 4 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | const symbolSupported = typeof Symbol === "function"; 2 | 3 | export const HYDRATE = symbolSupported ? Symbol("__hydrate__") : "__hydrate__"; 4 | -------------------------------------------------------------------------------- /spec/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "./_util/babel-resolve-paths-plugin", 4 | "transform-react-jsx" 5 | ], 6 | "presets": [ 7 | ["env", { "targets": { "node": "current" } }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import { default as React } from "react"; 2 | import { render } from "react-dom"; 3 | import StatefulApp from "./app"; 4 | 5 | render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /example/src/child-component/initial-state.js: -------------------------------------------------------------------------------- 1 | import { hydrate } from "../../.."; 2 | 3 | 4 | const IS_BROWSER = typeof window === "object"; 5 | 6 | 7 | export default IS_BROWSER ? 8 | hydrate() : 9 | () => ({ localValue: "local value" }); 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { provideState } from "./provide"; 2 | export { hydrate } from "./state"; 3 | export { injectState } from "./inject"; 4 | export { 5 | hardUpdate, 6 | softUpdate, 7 | update, 8 | mergeIntoState 9 | } from "./helpers"; 10 | -------------------------------------------------------------------------------- /example/src/state/index.js: -------------------------------------------------------------------------------- 1 | import { provideState } from "../../.."; 2 | 3 | import * as effects from "./effects"; 4 | import initialState from "./initial-state"; 5 | import computed from "./computed"; 6 | 7 | export default provideState({ effects, initialState, computed }); 8 | -------------------------------------------------------------------------------- /example/src/state/initial-state.js: -------------------------------------------------------------------------------- 1 | import { hydrate } from "../../.."; 2 | 3 | 4 | const IS_BROWSER = typeof window === "object"; 5 | 6 | 7 | export default IS_BROWSER ? 8 | hydrate(window.__state__) : 9 | () => ({ 10 | pending: false, 11 | todos: null 12 | }); 13 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 4 | 5 | test: 6 | override: 7 | - npm run build && npm run check 8 | 9 | machine: 10 | node: 11 | version: 8 12 | 13 | deployment: 14 | production: 15 | branch: master 16 | commands: 17 | - ./deploy.sh 18 | -------------------------------------------------------------------------------- /example/src/state/computed.js: -------------------------------------------------------------------------------- 1 | export default { 2 | todosType: ({ todos }) => typeof todos, 3 | todosLength: ({ todos }) => todos && todos.length, 4 | todosInfo: ({ todosType, todosLength, pending }) => 5 | pending ? 6 | "waiting on the data..." : 7 | `got a response of type '${todosType}' of length: ${todosLength}` 8 | }; 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-react-jsx" 4 | ], 5 | "presets": [ 6 | ["env", { "modules": false }] 7 | ], 8 | "env": { 9 | "commonjs": { 10 | "presets": [ 11 | "env" 12 | ] 13 | }, 14 | "umd": { 15 | "plugins": [ 16 | "external-helpers" 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/src/child-component/effects.js: -------------------------------------------------------------------------------- 1 | const IS_BROWSER = typeof window === "object"; 2 | 3 | 4 | export const initialize = effects => IS_BROWSER ? 5 | Promise.resolve() : 6 | new Promise(resolve => { 7 | setTimeout(() => { 8 | resolve(state => Object.assign({}, state, { 9 | localValue: "ssr initialized value" 10 | })); 11 | }, 2000); 12 | }); 13 | -------------------------------------------------------------------------------- /spec/_util/babel-resolve-paths-plugin.js: -------------------------------------------------------------------------------- 1 | /* global __dirname */ 2 | const { resolve } = require("path"); 3 | 4 | 5 | const freactalRoot = /^freactal(?=\/)?/; 6 | const replacement = resolve(__dirname, "../.."); 7 | 8 | 9 | module.exports = () => ({ 10 | visitor: { 11 | ImportDeclaration (path) { 12 | path.node.source.value = path.node.source.value.replace(freactalRoot, replacement); 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import { partialRender } from "./partial-render"; 2 | import { resolvePromiseTree } from "./resolve-promise-tree"; 3 | import { constructCapture, captureState } from "./capture"; 4 | 5 | 6 | export const initialize = rootNode => { 7 | const { state, context } = constructCapture(); 8 | return resolvePromiseTree(partialRender(rootNode, context)) 9 | .then(vdom => ({ vdom, state })); 10 | }; 11 | 12 | export { captureState }; 13 | -------------------------------------------------------------------------------- /src/server/set-state.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-invalid-this */ 2 | 3 | /** 4 | * Code originally borrowed from the Rapscallion project: 5 | * https://github.com/FormidableLabs/rapscallion/blob/44014d86a0855f7c3e438e6a9ee1e2ca07ff2cbe/src/render/state.js 6 | */ 7 | 8 | export function syncSetState (newState, cb) { 9 | // Mutation is faster and should be safe here. 10 | this.state = Object.assign( 11 | this.state, 12 | typeof newState === "function" ? 13 | newState(this.state, this.props) : 14 | newState 15 | ); 16 | if (cb) { cb.call(this); } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - formidable/configurations/es6-react-test 4 | 5 | globals: 6 | sinon: true 7 | expect: true 8 | global: true 9 | sandbox: true 10 | 11 | rules: 12 | arrow-parens: off 13 | 14 | func-style: 15 | - error 16 | - declaration 17 | - allowArrowFunctions: true 18 | 19 | space-before-function-paren: 20 | - error 21 | - always 22 | 23 | no-return-assign: off 24 | 25 | no-use-before-define: off 26 | 27 | no-sequences: off 28 | 29 | react/prop-types: off 30 | 31 | react/no-multi-comp: off 32 | 33 | no-unused-expressions: off 34 | 35 | import/no-unresolved: off 36 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | export const getEffects = (hocState, effectDefs, parentEffects) => { 2 | const applyReducer = reducer => { 3 | const result = reducer ? reducer(hocState.state) : null; 4 | if (result) { 5 | hocState.setState(result); 6 | } 7 | 8 | return result; 9 | }; 10 | 11 | const effects = Object.keys(effectDefs).reduce((memo, effectKey) => { 12 | const effectFn = effectDefs[effectKey]; 13 | 14 | memo[effectKey] = (...args) => new Promise(resolve => 15 | resolve(effectFn(effects, ...args)) 16 | ) 17 | .then(applyReducer); 18 | 19 | return memo; 20 | }, Object.assign({}, parentEffects, { initialize: null })); 21 | 22 | return effects; 23 | }; 24 | -------------------------------------------------------------------------------- /example/src/app.js: -------------------------------------------------------------------------------- 1 | import { injectState } from "../.."; 2 | import { default as React } from "react"; 3 | 4 | import withState from "./state"; 5 | import ChildComponent from "./child-component"; 6 | 7 | 8 | export const App = ({ rootProp, state, effects }) => { 9 | return ( 10 |
11 |
{ rootProp }
12 |
{ state.todosInfo }
13 | 16 |
17 | And here's the child: 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | 25 | export default withState(injectState(App)); 26 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "babel-cli": "^6.24.0", 7 | "babel-core": "6.22.1", 8 | "babel-loader": "6.2.10", 9 | "babel-preset-react-app": "^2.2.0", 10 | "babel-runtime": "^6.20.0", 11 | "express": "^4.15.2", 12 | "isomorphic-fetch": "^2.2.1", 13 | "react": "^15.4.2", 14 | "react-dom": "^15.4.2", 15 | "serve-static": "^1.12.1", 16 | "webpack": "1.14.0", 17 | "webpack-dev-middleware": "^1.10.1" 18 | }, 19 | "scripts": { 20 | "start": "NODE_ENV=development babel-node ./src/server" 21 | }, 22 | "babel": { 23 | "presets": [ 24 | "react-app" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | /lib 40 | /es 41 | /umd 42 | 43 | yarn.lock 44 | -------------------------------------------------------------------------------- /src/server/capture.js: -------------------------------------------------------------------------------- 1 | import { default as React, Component } from "react"; 2 | 3 | import { contextTypes } from "../context"; 4 | 5 | 6 | export const constructCapture = () => { 7 | const state = []; 8 | const context = { 9 | freactal: { captureState: containerState => state.push(containerState) } 10 | }; 11 | return { state, context }; 12 | }; 13 | 14 | 15 | class CaptureState extends Component { 16 | getChildContext () { 17 | return this.props.capture.context; 18 | } 19 | 20 | render () { 21 | return this.props.children; 22 | } 23 | } 24 | CaptureState.childContextTypes = contextTypes; 25 | 26 | export const captureState = rootComponent => { 27 | const capture = constructCapture(); 28 | 29 | const Captured = ( 30 | 31 | { rootComponent } 32 | 33 | ); 34 | 35 | return { 36 | state: capture.state, 37 | Captured 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /example/src/state/effects.js: -------------------------------------------------------------------------------- 1 | import "isomorphic-fetch"; 2 | import { update, mergeIntoState } from "../../.."; 3 | 4 | const IS_BROWSER = typeof window === "object"; 5 | 6 | const wrapWithPending = cb => (effects, ...args) => effects.setDataPending(true) 7 | .then(() => cb(...args)) 8 | .then(value => effects.setDataPending(false).then(() => value)); 9 | 10 | export const setDataPending = update((state, value) => ({ pending: value })); 11 | 12 | const delay = ms => val => new Promise(resolve => setTimeout(() => resolve(val), ms)); 13 | 14 | export const fetchTodos = wrapWithPending(url => 15 | fetch(url) 16 | .then(delay(2000)) 17 | .then(result => result.json()) 18 | .then(json => mergeIntoState({ todos: json })) 19 | ); 20 | 21 | export const initialize = effects => 22 | IS_BROWSER ? 23 | Promise.resolve() : 24 | fetch("https://jsonplaceholder.typicode.com/todos") 25 | .then(result => result.json()) 26 | .then(json => mergeIntoState({ todos: json.slice(0, 10) })); 27 | -------------------------------------------------------------------------------- /example/src/child-component/index.js: -------------------------------------------------------------------------------- 1 | import { provideState, injectState } from "../../.."; 2 | import { default as React } from "react"; 3 | 4 | import * as effectDefs from "./effects"; 5 | import initialState from "./initial-state"; 6 | import computed from "./computed"; 7 | 8 | export const withState = provideState({ effects: effectDefs, initialState, computed }); 9 | 10 | export const ChildComponent = ({ state, effects }) => ( 11 |
12 |
This is a subcomponent, with its own state!
13 |
{`Here is a local value '${state.localValue}' and its computed length '${state.localValueLength}'`}
14 |
15 |
{`It can access parent state and effects too (!): ${state.todosLength}`}
16 | 19 |
20 |
21 | ); 22 | 23 | export default withState(injectState(ChildComponent)); 24 | -------------------------------------------------------------------------------- /src/server/resolve-promise-tree.js: -------------------------------------------------------------------------------- 1 | const traverseTree = (obj, enter) => { 2 | if (obj && obj.props && obj.props.children) { 3 | if (Array.isArray(obj.props.children)) { 4 | obj.props.children.forEach( 5 | (child, idx) => enter(obj.props.children, idx, child) 6 | ); 7 | } else { 8 | enter(obj.props, "children", obj.props.children); 9 | } 10 | } 11 | }; 12 | 13 | export const resolvePromiseTree = obj => { 14 | if (obj && typeof obj.then === "function") { 15 | return obj.then(resolvePromiseTree); 16 | } 17 | 18 | const leavesToResolve = []; 19 | 20 | const visitor = (parent, key, node) => { 21 | if (node && typeof node.then === "function") { 22 | leavesToResolve.push(node.then(resolvedValue => { 23 | parent[key] = resolvedValue; 24 | return resolvePromiseTree(resolvedValue); 25 | })); 26 | } else { 27 | traverseTree(node, visitor); 28 | } 29 | }; 30 | 31 | traverseTree(obj, visitor); 32 | 33 | return Promise.all(leavesToResolve).then(() => obj); 34 | }; 35 | 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import resolve from "rollup-plugin-node-resolve"; 4 | import uglify from "rollup-plugin-uglify"; 5 | 6 | const name = process.env.NODE_ENV === "production" 7 | ? "freactal.umd.min.js" 8 | : "freactal.umd.js"; 9 | 10 | const config = { 11 | input: "./src/index.js", 12 | output: { 13 | file: `./umd/${name}`, 14 | directory: "umd", 15 | format: "umd" 16 | }, 17 | name: "Freactal", 18 | external: ["react"], 19 | globals: { 20 | "react": "React" 21 | }, 22 | plugins: [ 23 | resolve(), 24 | commonjs({ 25 | include: [ 26 | "node_modules/**" 27 | ], 28 | namedExports: { 29 | // Manually specify named `import`s from CJS libraries 30 | "node_modules/prop-types/index.js": [ 31 | "object" 32 | ] 33 | } 34 | }), 35 | babel() 36 | ] 37 | }; 38 | 39 | if (process.env.NODE_ENV === "production") { 40 | config.plugins.push(uglify()); 41 | } 42 | 43 | export default config; 44 | -------------------------------------------------------------------------------- /src/server/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Code originally borrowed from the Rapscallion project: 3 | * https://github.com/FormidableLabs/rapscallion/blob/44014d86a0855f7c3e438e6a9ee1e2ca07ff2cbe/src/render/context.js 4 | */ 5 | 6 | 7 | const EMPTY_CONTEXT = Object.freeze({}); 8 | 9 | 10 | export function getChildContext (componentPrototype, instance, context) { 11 | if (componentPrototype.childContextTypes) { 12 | return Object.assign(Object.create(null), context, instance.getChildContext()); 13 | } 14 | return context; 15 | } 16 | 17 | export function getContext (componentPrototype, context) { 18 | if (componentPrototype.contextTypes) { 19 | const contextTypes = componentPrototype.contextTypes; 20 | return Object.keys(context).reduce( 21 | (memo, contextKey) => { 22 | if (contextKey in contextTypes) { 23 | memo[contextKey] = context[contextKey]; 24 | } 25 | return memo; 26 | }, 27 | Object.create(null) 28 | ); 29 | } 30 | return EMPTY_CONTEXT; 31 | } 32 | 33 | export function getRootContext () { 34 | return Object.create(null); 35 | } 36 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PULL_REQUEST_NUMBER=$(git show HEAD --format=format:%s | sed -nE 's/Merge pull request #([0-9]+).*/\1/p') 4 | 5 | if [ -z "$PULL_REQUEST_NUMBER" ]; then 6 | echo "No pull request number found; aborting publish." 7 | exit 0 8 | fi 9 | 10 | echo "Detected pull request #$PULL_REQUEST_NUMBER." 11 | SEMVER_CHANGE=$(curl "https://maintainerd.divmain.com/api/semver?repoPath=FormidableLabs/freactal&installationId=37499&prNumber=$PULL_REQUEST_NUMBER") 12 | if [ -z "$SEMVER_CHANGE" ]; then 13 | echo "No semver selection found; aborting publish." 14 | exit 0 15 | fi 16 | 17 | echo "Detected semantic version change of $SEMVER_CHANGE." 18 | 19 | # CI might leave the working directory in an unclean state. 20 | git reset --hard 21 | 22 | git config --global user.name "Dale Bustad (bot)" 23 | git config --global user.email "dale@divmain.com" 24 | 25 | eval npm version "$SEMVER_CHANGE" 26 | npm publish 27 | 28 | git remote add origin-deploy https://${GH_TOKEN}@github.com/FormidableLabs/freactal.git > /dev/null 2>&1 29 | git push --quiet --tags origin-deploy master 30 | 31 | echo "Done!" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dale Bustad 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | let warningShown = false; 2 | const displayDeprecationMessage = () => { 3 | if (warningShown) { return; } 4 | warningShown = true; 5 | // eslint-disable-next-line no-console 6 | console.log("Both `hardUpdate` and `softUpdate` are deprecated. Please use `update` instead."); 7 | }; 8 | 9 | export const hardUpdate = (newState, showWarning = true) => { 10 | if (showWarning) { displayDeprecationMessage(); } 11 | return () => state => Object.assign({}, state, newState); 12 | }; 13 | 14 | export const softUpdate = (fn, showWarning = true) => { 15 | if (showWarning) { displayDeprecationMessage(); } 16 | return (effects, ...args) => state => Object.assign({}, state, fn(state, ...args)); 17 | }; 18 | 19 | export const update = fnOrNewState => { 20 | if (typeof fnOrNewState === "function") { 21 | return softUpdate(fnOrNewState, false); 22 | } 23 | 24 | if (typeof fnOrNewState === "object") { 25 | return hardUpdate(fnOrNewState, false); 26 | } 27 | 28 | throw new Error("update must receive a reducer function or object to merge as its argument."); 29 | }; 30 | 31 | export const mergeIntoState = dataToMerge => state => Object.assign({}, state, dataToMerge); 32 | -------------------------------------------------------------------------------- /spec/index.js: -------------------------------------------------------------------------------- 1 | /* global __dirname */ 2 | 3 | Error.stackTraceLimit = Infinity; 4 | 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | const { jsdom } = require("jsdom"); 8 | 9 | require("babel-core/register"); 10 | 11 | const chai = require("chai"); 12 | const sinonChai = require("sinon-chai"); 13 | const chaiEnzyme = require("chai-enzyme"); 14 | global.sinon = require("sinon"); 15 | 16 | chai.config.includeStack = true; 17 | chai.use(sinonChai); 18 | chai.use(chaiEnzyme()); 19 | 20 | global.expect = chai.expect; 21 | global.AssertionError = chai.AssertionError; 22 | global.Assertion = chai.Assertion; 23 | global.assert = chai.assert; 24 | 25 | beforeEach(() => { 26 | global.sandbox = sinon.sandbox.create(); 27 | }); 28 | 29 | afterEach(() => { 30 | global.sandbox.restore(); 31 | delete global.sandbox; 32 | }); 33 | 34 | const document = global.document = jsdom(""); 35 | global.window = document.defaultView; 36 | Object.keys(document.defaultView).forEach((property) => { 37 | if (typeof global[property] === "undefined") { 38 | global[property] = document.defaultView[property]; 39 | } 40 | }); 41 | 42 | global.navigator = { userAgent: "node.js" }; 43 | 44 | const specFile = /\.spec\.js$/; 45 | const recursiveRequire = (basepath, cb) => fs.readdirSync(basepath).forEach(filename => { 46 | const filepath = path.join(basepath, filename); 47 | if (fs.statSync(filepath).isDirectory()) { 48 | describe(filename, () => recursiveRequire(filepath, cb)); 49 | } else if (specFile.test(filename)) { 50 | require(filepath); 51 | } 52 | }); 53 | 54 | recursiveRequire(__dirname); 55 | -------------------------------------------------------------------------------- /spec/integration/nested-state-injection.spec.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | 4 | import { provideState, injectState, softUpdate } from "freactal"; 5 | 6 | 7 | const Child = ({ state }) => ( 8 |
{ state.toggleMe ? "true" : "false" }
9 | ); 10 | const ChildWithState = injectState(Child); 11 | const wrapChildWithState = provideState({}); 12 | const StatefulChild = wrapChildWithState(ChildWithState); 13 | 14 | const Parent = ({ state: { toggleMe }, children }) => ( 15 |
16 |
{ toggleMe ? "true" : "false" }
17 | { children } 18 |
19 | ); 20 | const ParentWithState = injectState(Parent); 21 | 22 | const Root = () => ( 23 | 24 | 25 | 26 | ); 27 | const wrapRootWithState = provideState({ 28 | initialState: () => ({ 29 | toggleMe: true 30 | }), 31 | effects: { 32 | toggle: softUpdate(state => ({ toggleMe: !state.toggleMe })) 33 | } 34 | }); 35 | const StatefulRoot = wrapRootWithState(Root); 36 | 37 | 38 | describe("nested state injections", () => { 39 | it("children are updated when intermediate state injections are present", async () => { 40 | const el = mount(); 41 | expect(el.find(".parent-value").text()).to.equal("true"); 42 | expect(el.find(".child-value").text()).to.equal("true"); 43 | await el.instance().effects.toggle(); 44 | expect(el.find(".parent-value").text()).to.equal("false"); 45 | expect(el.find(".child-value").text()).to.equal("false"); 46 | }); 47 | }); 48 | 49 | -------------------------------------------------------------------------------- /example/src/server.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { renderToString } from "react-dom/server"; 3 | import express from "express"; 4 | import webpack from "webpack"; 5 | import webpackMiddleware from "webpack-dev-middleware"; 6 | import { initialize } from "../../server"; 7 | 8 | import StatefulRootComponent from "./app"; 9 | 10 | 11 | const PORT = process.env.PORT || 1337; 12 | Error.stackTraceLimit = Infinity; 13 | 14 | 15 | const app = express(); 16 | 17 | app.use(webpackMiddleware(webpack({ 18 | entry: "./src/index.js", 19 | output: { 20 | path: "/", 21 | filename: "main.js" 22 | }, 23 | module: { 24 | loaders: [{ 25 | test: /\.(js|jsx)$/, 26 | loader: "babel", 27 | include: __dirname 28 | }] 29 | } 30 | }), { 31 | watchOptions: { 32 | aggregateTimeout: 300, 33 | poll: true 34 | }, 35 | publicPath: "/assets/", 36 | stats: { 37 | colors: true 38 | } 39 | })); 40 | 41 | const boilerplate = (componentHtml, state) => ` 42 | 43 | SSR Example 44 | 45 |
${componentHtml}
46 | 49 | 50 | 51 | 52 | `; 53 | 54 | 55 | app.route("/").get((req, res) => { 56 | initialize() 57 | .then(({ vdom, state }) => { 58 | const appHtml = renderToString(vdom); 59 | const html = boilerplate(appHtml, state); 60 | return res.send(html).end(); 61 | }) 62 | .catch(err => { 63 | console.log(err.stack || err); 64 | }); 65 | }); 66 | 67 | app.listen(PORT, () => { 68 | console.log(`server started on port ${PORT}...`); 69 | }); 70 | -------------------------------------------------------------------------------- /.maintainerd: -------------------------------------------------------------------------------- 1 | log: true 2 | pullRequest: 3 | preamble: > 4 | The maintainers of this repo require that all pull request submitters adhere to the following: 5 | items: 6 | - prompt: > 7 | I have read and will comply with the 8 | [contribution guidelines](https://github.com/FormidableLabs/freactal/blob/master/CONTRIBUTE.md). 9 | default: false 10 | required: true 11 | - prompt: > 12 | I have read and will comply with the 13 | [code of conduct](https://github.com/FormidableLabs/freactal/blob/master/CONTRIBUTE.md). 14 | default: false 15 | required: true 16 | - prompt: All related documentation has been updated to reflect the changes made. 17 | default: false 18 | required: true 19 | - prompt: My commit messages are cleaned up and ready to merge. 20 | default: false 21 | required: true 22 | semver: 23 | enabled: true 24 | commit: 25 | subject: 26 | mustHaveLengthBetween: [8, 100] 27 | mustNotMatch: !!js/regexp /^fixup!/ 28 | message: 29 | enforceEmptySecondLine: true 30 | linesMustHaveLengthBetween: [0, 100] 31 | issue: 32 | onLabelAdded: 33 | not-enough-information: 34 | action: comment 35 | data: | 36 | This issue has been tagged with the `not-enough-information` label. In order for us to help you, 37 | please respond with the following information: 38 | 39 | - A description of the problem, including any relevant error output that can find. 40 | - A full repro, if possible. Otherwise, steps to reproduce. 41 | - The versions of the packages that you are using. 42 | - The operating system that you are using. 43 | - The browser or environment where the issue occurs. 44 | 45 | If we receive no response to this issue within 2 weeks, the issue will be closed. If that happens, 46 | feel free to re-open with the requested information. Thank you! 47 | -------------------------------------------------------------------------------- /src/server/partial-render.js: -------------------------------------------------------------------------------- 1 | import { getContext, getChildContext } from "./context"; 2 | import { syncSetState } from "./set-state"; 3 | 4 | 5 | const isReactComponent = node => node && typeof node.type === "function"; 6 | const isReactVdom = node => node && typeof node.type === "string"; 7 | 8 | export const partialRender = (node, context) => { 9 | if (isReactComponent(node)) { 10 | return renderComponent(node, context); 11 | } else if (isReactVdom(node)) { 12 | return partiallyRenderVdom(node, context); 13 | } else { 14 | return node; 15 | } 16 | }; 17 | 18 | const isStatefulComponent = node => node.type.prototype && node.type.prototype.isReactComponent; 19 | 20 | const renderComponent = (node, context) => { 21 | const componentContext = getContext(node.type, context); 22 | 23 | if (!(isStatefulComponent(node))) { 24 | // Vanilla SFC. 25 | return partialRender(node.type(node.props, componentContext), context); 26 | } else { 27 | // eslint-disable-next-line new-cap 28 | const instance = new node.type(node.props, componentContext); 29 | 30 | if (typeof instance.componentWillMount === "function") { 31 | instance.setState = syncSetState; 32 | instance.componentWillMount(); 33 | } 34 | 35 | const renderInstance = () => { 36 | const childContext = getChildContext(node.type, instance, context); 37 | return partialRender(instance.render(), childContext); 38 | }; 39 | 40 | return instance.effects && typeof instance.effects.initialize === "function" ? 41 | instance.effects.initialize().then(renderInstance) : 42 | renderInstance(); 43 | } 44 | }; 45 | 46 | const assign = Object.assign; 47 | 48 | const partiallyRenderVdom = (node, context) => { 49 | if (node.props.children) { 50 | const children = Array.isArray(node.props.children) ? 51 | node.props.children.map(child => partialRender(child, context)) : 52 | partialRender(node.props.children, context); 53 | 54 | node = assign({}, node, { 55 | props: assign({}, node.props, { children }) 56 | }); 57 | } 58 | return node; 59 | }; 60 | 61 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | If you're interested in adding features, reporting/fixing bugs, or just discussing the future of freactal, this is the place to start. Please feel free to reach out if you have questions or comments by opening an [issue](https://github.com/FormidableLabs/freactal/issues). 4 | 5 | 6 | ## Adding new features 7 | 8 | If there's a feature you'd like to see that isn't already in-progress or documented, there are two ways you can go about it: 9 | 10 | - open an issue for discussion; or 11 | - fork and submit a PR. 12 | 13 | Either way is fine, so long as you remember your PR may not be accepted as-is or at all. If a feature request is reasonable, though, it'll most likely be included one way or another. 14 | 15 | Some other things to remember: 16 | 17 | - Please respect the project layout and hierarchy, to keep things organized. 18 | - All code is linted. Outside of that, please try to conform to the code style and idioms that are used throughout the project. 19 | - Include descriptions for issues and PRs. 20 | - Please comply with the project's [code of conduct](./COC.md). 21 | 22 | I'm busy and travel sometimes for work - so if I don't respond immediately, please be patient. I promise to reply! 23 | 24 | ## Commit message structure 25 | 26 | Please follow the following commit message structure when submitting your pull request: 27 | 28 | TYPE: Short commit message 29 | 30 | Detailed 31 | commit 32 | info 33 | 34 | For the value of **`TYPE`**, please use one of **`Feature`**, **`Enhancement`**, or **`Fix`**. 35 | 36 | This is required in order to help us automate tasks such as changelog generation. 37 | 38 | 39 | # Bugs 40 | 41 | If you encounter a bug, please check for an open issue that already captures the problem you've run into. If it doesn't exist yet, create it! 42 | 43 | Please include as much information as possible, including: 44 | 45 | - A full repro, if possible. 46 | - The versions of the packages that you're using. 47 | - The browser or environment where the issue occurs. 48 | - Any error messages or debug output that seems relevant. 49 | 50 | If you're interested in tackling a bug, please say so. 51 | 52 | 53 | # Documentation 54 | 55 | If you make changes, please remember to update the documentation to reflect the new behavior. 56 | 57 | # Publishing 58 | 59 | All changes are published automatically whenever a pull request is merged. As part of the PR process, you will be asked to provide the information necessary to make that happen. 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freactal", 3 | "version": "2.0.3", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "jsnext:main": "es/index.js", 8 | "scripts": { 9 | "build": "npm run clean && npm run build-lib && npm run build-es && npm run build-umd", 10 | "build-lib": "BABEL_ENV=commonjs babel -d lib src", 11 | "build-es": "babel -d es src", 12 | "build-umd": "npm run build-umd:dev && npm run build-umd:prod", 13 | "build-umd:dev": "BABEL_ENV=umd rollup -c", 14 | "build-umd:prod": "BABEL_ENV=umd NODE_ENV=production rollup -c", 15 | "clean": "rm -rf lib es umd", 16 | "watch": "nodemon --watch src --exec npm run build-lib", 17 | "check": "npm run lint && npm run test", 18 | "test": "mocha spec", 19 | "lint": "eslint *.js src spec", 20 | "preversion": "npm run build" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/FormidableLabs/freactal.git" 25 | }, 26 | "files": [ 27 | "es", 28 | "lib", 29 | "umd" 30 | ], 31 | "keywords": [ 32 | "react", 33 | "state", 34 | "hoc" 35 | ], 36 | "author": "Dale Bustad ", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/FormidableLabs/freactal/issues" 40 | }, 41 | "homepage": "https://github.com/FormidableLabs/freactal#readme", 42 | "peerDependencies": { 43 | "react": "^15.6.2 || ^16.0.0" 44 | }, 45 | "devDependencies": { 46 | "babel-cli": "^6.24.0", 47 | "babel-eslint": "^7.2.1", 48 | "babel-plugin-external-helpers": "^6.22.0", 49 | "babel-plugin-transform-react-jsx": "^6.23.0", 50 | "babel-plugin-transform-react-remove-prop-types": "^0.4.10", 51 | "babel-preset-env": "^1.6.0", 52 | "chai": "^3.5.0", 53 | "chai-enzyme": "1.0.0-beta.0", 54 | "cheerio": "^0.22.0", 55 | "enzyme": "^2.8.2", 56 | "eslint": "^3.18.0", 57 | "eslint-config-formidable": "^2.0.1", 58 | "eslint-plugin-filenames": "^1.1.0", 59 | "eslint-plugin-import": "^2.2.0", 60 | "eslint-plugin-react": "^6.10.3", 61 | "jsdom": "^9.12.0", 62 | "mocha": "^3.2.0", 63 | "nodemon": "^1.11.0", 64 | "react": "15.6.2", 65 | "react-dom": "15.6.2", 66 | "react-test-renderer": "^15.5.4", 67 | "rollup": "^0.52.0", 68 | "rollup-plugin-babel": "^3.0.2", 69 | "rollup-plugin-commonjs": "^8.2.6", 70 | "rollup-plugin-node-resolve": "^3.0.2", 71 | "rollup-plugin-uglify": "^2.0.1", 72 | "sinon": "^2.1.0", 73 | "sinon-chai": "^2.9.0" 74 | }, 75 | "dependencies": { 76 | "prop-types": "15.6.0" 77 | }, 78 | "sideEffects": false 79 | } 80 | -------------------------------------------------------------------------------- /src/inject.js: -------------------------------------------------------------------------------- 1 | import { default as React, Component } from "react"; 2 | 3 | import { contextTypes } from "./context"; 4 | 5 | 6 | export class BaseInjectStateHoc extends Component { 7 | componentDidMount () { 8 | this.mounted = true; 9 | this.unsubscribe = this.context.freactal.subscribe(this.update.bind(this)); 10 | } 11 | 12 | componentWillReceiveProps () { 13 | this.usedKeys = null; 14 | } 15 | 16 | componentWillUnmount () { 17 | this.mounted = false; 18 | // this.unsubscribe may be undefined due to an error in child render 19 | if (this.unsubscribe) { 20 | this.unsubscribe(); 21 | } 22 | } 23 | 24 | update (changedKeys) { 25 | return this.mounted && this.shouldUpdate(changedKeys, this.usedKeys) ? 26 | new Promise(resolve => this.forceUpdate(resolve)) : 27 | Promise.resolve(); 28 | } 29 | 30 | getTrackedState () { 31 | const state = this.context.freactal.state; 32 | const trackedState = Object.create(null); 33 | const usedKeys = this.usedKeys = Object.create(null); 34 | 35 | Object.keys(state).forEach(key => { 36 | usedKeys[key] = false; 37 | 38 | Object.defineProperty(trackedState, key, { 39 | enumerable: true, 40 | get () { 41 | usedKeys[key] = true; 42 | return state[key]; 43 | } 44 | }); 45 | }); 46 | 47 | return trackedState; 48 | } 49 | 50 | render () { 51 | const props = Object.assign({}, this.props); 52 | if (this.keys) { 53 | this.keys.forEach(key => props[key] = this.context.freactal.state[key]); 54 | } else { 55 | props.state = this.getTrackedState(); 56 | } 57 | 58 | return ( 59 | 63 | ); 64 | } 65 | } 66 | 67 | export const injectState = (StatelessComponent, keys = null) => { 68 | const shouldUpdate = keys ? 69 | changedKeys => keys.some(key => changedKeys[key], false) : 70 | (changedKeys, usedKeys) => usedKeys ? 71 | Object.keys(usedKeys).some(key => usedKeys[key] && changedKeys[key]) : 72 | true; 73 | 74 | class InjectStateHoc extends BaseInjectStateHoc { 75 | constructor (...args) { 76 | super(...args); 77 | if (!this.context.freactal) { 78 | throw new Error("Attempted to inject state without parent Freactal state container."); 79 | } 80 | 81 | this.keys = keys; 82 | this.shouldUpdate = shouldUpdate; 83 | this.StatelessComponent = StatelessComponent; 84 | } 85 | } 86 | 87 | InjectStateHoc.contextTypes = contextTypes; 88 | 89 | return InjectStateHoc; 90 | }; 91 | -------------------------------------------------------------------------------- /spec/unit/effects.spec.js: -------------------------------------------------------------------------------- 1 | import { getEffects } from "freactal/lib/effects"; 2 | 3 | 4 | describe("effects", () => { 5 | it("returns an object with same keys as effect definition object", () => { 6 | const hocState = { 7 | setState: sinon.spy(), 8 | state: {} 9 | }; 10 | const effects = getEffects(hocState, { 11 | effectA: () => state => state, 12 | effectB: () => state => state, 13 | effectC: () => state => state 14 | }); 15 | 16 | expect(effects).to.have.property("effectA"); 17 | expect(effects).to.have.property("effectB"); 18 | expect(effects).to.have.property("effectC"); 19 | }); 20 | 21 | it("invokes the effect function with effects object", () => { 22 | const hocState = { 23 | setState: sinon.spy(), 24 | state: {} 25 | }; 26 | const effects = getEffects(hocState, { 27 | effectA: _effects => Promise.resolve().then(() => { 28 | expect(_effects).to.equal(effects); 29 | return state => state; 30 | }) 31 | }); 32 | 33 | return effects.effectA(); 34 | }); 35 | 36 | it("can access their parent effects", () => { 37 | const parentHocState = { 38 | setState: sinon.spy(), 39 | state: {} 40 | }; 41 | const parentEffects = getEffects(parentHocState, { 42 | parentEffect: (effects, parentVal) => () => ({ parentVal }) 43 | }); 44 | 45 | const childHocState = { 46 | setState: sinon.spy(), 47 | state: {} 48 | }; 49 | const childEffects = getEffects(childHocState, { 50 | childEffect: (effects, parentVal, childVal) => effects.parentEffect(parentVal) 51 | .then(() => () => ({ childVal })) 52 | }, parentEffects); 53 | 54 | return childEffects.childEffect("parent", "child").then(() => { 55 | expect(parentHocState.setState).to.have.been.calledOnce; 56 | expect(parentHocState.setState).to.have.been.calledWith({ parentVal: "parent" }); 57 | expect(childHocState.setState).to.have.been.calledOnce; 58 | expect(childHocState.setState).to.have.been.calledWith({ childVal: "child" }); 59 | }); 60 | }); 61 | 62 | it("can be defined as an async function", async () => { 63 | let state = { loading: 0, data: null }; 64 | const hocState = { 65 | setState: sinon.spy(newState => Promise.resolve(hocState.state = state = newState)), 66 | state 67 | }; 68 | 69 | const getData = () => Promise.resolve({ data: "data!" }); 70 | 71 | const effects = getEffects(hocState, { 72 | getData: async ({ setLoading }) => { 73 | await setLoading(true); 74 | expect(state).to.have.property("loading", 1); 75 | expect(state).to.have.property("data", null); 76 | const { data } = await getData(); 77 | expect(state).to.have.property("data", null); 78 | await setLoading(false); 79 | expect(state).to.have.property("loading", 0); 80 | return oldState => Object.assign({}, oldState, { data }); 81 | }, 82 | setLoading: ({ loading }, isStarting) => oldState => 83 | Object.assign({}, oldState, { loading: oldState.loading + (isStarting ? 1 : -1) }) 84 | }); 85 | 86 | await effects.getData(); 87 | 88 | expect(state).to.have.property("data", "data!"); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /COC.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Aggresive and/or demanding language 32 | * Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying the standards of acceptable 38 | behavior and are expected to take appropriate and fair corrective action in 39 | response to any instances of unacceptable behavior. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, or 42 | reject comments, commits, code, wiki edits, issues, and other contributions 43 | that are not aligned to this Code of Conduct, or to ban temporarily or 44 | permanently any contributor for other behaviors that they deem inappropriate, 45 | threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies both within project spaces and in public spaces 50 | when an individual is representing the project or its community. Examples of 51 | representing a project or community include using an official project e-mail 52 | address, posting via an official social media account, or acting as an appointed 53 | representative at an online or offline event. Representation of a project may be 54 | further defined and clarified by project maintainers. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported by contacting the author at . All 60 | complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The maintainers are 62 | obligated to protect confidentiality with regard to the reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 72 | available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | import { HYDRATE } from "./common"; 2 | 3 | 4 | export const graftParentState = (state, parentState) => { 5 | if (parentState) { 6 | Object.keys(parentState).forEach(parentKey => { 7 | if (parentKey in state) { return; } 8 | Object.defineProperty(state, parentKey, { 9 | enumerable: true, 10 | get () { return parentState[parentKey]; } 11 | }); 12 | }); 13 | } 14 | return state; 15 | }; 16 | 17 | export class StateContainer { 18 | // eslint-disable-next-line max-params 19 | constructor ( 20 | initialState, 21 | computed, 22 | pushUpdate 23 | ) { 24 | this.state = initialState; 25 | 26 | this.cachedState = Object.create(null); 27 | this.computedDependants = Object.create(null); 28 | 29 | this.computed = computed; 30 | this.pushUpdate = pushUpdate; 31 | 32 | this.getTrackedState = this.getTrackedState.bind(this); 33 | } 34 | 35 | getTrackedState (computedKey, stateWithComputed, accessibleKeys) { 36 | const { computedDependants, state } = this; 37 | const stateProxy = Object.create(null); 38 | 39 | accessibleKeys.forEach(key => { 40 | Object.defineProperty(stateProxy, key, { 41 | get () { 42 | computedDependants[key] = computedDependants[key] || Object.create(null); 43 | computedDependants[key][computedKey] = true; 44 | return key in state ? 45 | state[key] : 46 | stateWithComputed[key]; 47 | } 48 | }); 49 | }); 50 | 51 | return stateProxy; 52 | } 53 | 54 | defineComputedStateProperties (stateWithComputed, parentKeys) { 55 | const { cachedState, getTrackedState, computed } = this; 56 | 57 | const computedKeys = Object.keys(computed); 58 | const accessibleKeys = [].concat(computedKeys, Object.keys(stateWithComputed), parentKeys); 59 | 60 | computedKeys.forEach(computedKey => { 61 | const trackedState = getTrackedState(computedKey, stateWithComputed, accessibleKeys); 62 | 63 | Object.defineProperty(stateWithComputed, computedKey, { 64 | enumerable: true, 65 | get () { 66 | if (computedKey in cachedState) { return cachedState[computedKey]; } 67 | return cachedState[computedKey] = computed[computedKey](trackedState); 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | getState (parentKeys) { 74 | parentKeys = parentKeys || []; 75 | const stateWithComputed = Object.create(null); 76 | Object.assign(stateWithComputed, this.state); 77 | this.defineComputedStateProperties(stateWithComputed, parentKeys); 78 | return stateWithComputed; 79 | } 80 | 81 | invalidateCache (key) { 82 | const valuesDependingOnKey = Object.keys(this.computedDependants[key] || {}); 83 | 84 | valuesDependingOnKey.forEach(dependantKey => { 85 | delete this.cachedState[dependantKey]; 86 | this.invalidateCache(dependantKey); 87 | }); 88 | } 89 | 90 | set (key, newVal) { 91 | const oldVal = this.state[key]; 92 | 93 | if (oldVal === newVal) { return; } 94 | 95 | this.invalidateCache(key); 96 | this.state[key] = newVal; 97 | } 98 | 99 | setState (newState) { 100 | const allKeys = Object.keys(Object.assign({}, this.state, newState)); 101 | const changedKeys = Object.create(null); 102 | 103 | allKeys.forEach(key => { 104 | const oldValue = this.state[key]; 105 | this.set(key, newState[key]); 106 | if (oldValue !== newState[key]) { changedKeys[key] = true; } 107 | }); 108 | 109 | return this.pushUpdate(changedKeys); 110 | } 111 | } 112 | 113 | export const hydrate = bootstrapState => (props, context) => { 114 | if (context.freactal && context.freactal.getNextContainerState) { 115 | return context.freactal.getNextContainerState(); 116 | } 117 | 118 | let containerIdx = 1; 119 | return Object.assign({ 120 | [HYDRATE]: () => bootstrapState[containerIdx++] 121 | }, bootstrapState[0]); 122 | }; 123 | -------------------------------------------------------------------------------- /spec/unit/inject.spec.js: -------------------------------------------------------------------------------- 1 | import { default as React, Component } from "react"; 2 | import { mount } from "enzyme"; 3 | 4 | import { injectState, BaseInjectStateHoc } from "freactal/lib/inject"; 5 | 6 | 7 | const StatelessComponent = ({ state, useState }) => useState ? 8 |
{state.stateKey}
: 9 |
; 10 | const getInjectedEl = (freactalCxt, keys, useState = false) => { 11 | const Injected = injectState(StatelessComponent, keys); 12 | return mount(, { 13 | context: { 14 | freactal: freactalCxt 15 | } 16 | }); 17 | }; 18 | 19 | 20 | describe("injected state", () => { 21 | it("throws an error if used without state in the tree", () => { 22 | expect(() => getInjectedEl()) 23 | .to.throw("Attempted to inject state without parent Freactal state container."); 24 | }); 25 | 26 | it("subscribes to state changes", () => { 27 | const update = sandbox.stub(BaseInjectStateHoc.prototype, "update"); 28 | 29 | const cxt = { 30 | state: {}, 31 | effects: {}, 32 | subscribe: sandbox.spy() 33 | }; 34 | 35 | getInjectedEl(cxt); 36 | 37 | expect(cxt.subscribe).to.have.been.calledOnce; 38 | expect(update).not.to.have.been.called; 39 | cxt.subscribe.args[0][0]("hi"); 40 | expect(update).to.have.been.calledWith("hi"); 41 | }); 42 | 43 | describe("for explicitly defined keys", () => { 44 | it("is updated when specified keys change", () => { 45 | sandbox.stub(Component.prototype, "forceUpdate"); 46 | 47 | const cxt = { 48 | state: {}, 49 | effects: {}, 50 | subscribe: sandbox.spy() 51 | }; 52 | 53 | getInjectedEl(cxt, ["stateKey"]); 54 | const instanceUpdate = cxt.subscribe.args[0][0]; 55 | 56 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 57 | instanceUpdate({ 58 | otherStateKey: true, 59 | stateKey: true 60 | }); 61 | expect(Component.prototype.forceUpdate).to.have.been.calledOnce; 62 | }); 63 | 64 | it("is not updated when non-specified keys change", () => { 65 | sandbox.stub(Component.prototype, "forceUpdate"); 66 | 67 | const cxt = { 68 | state: {}, 69 | effects: {}, 70 | subscribe: sandbox.spy() 71 | }; 72 | 73 | getInjectedEl(cxt, ["stateKey"]); 74 | const instanceUpdate = cxt.subscribe.args[0][0]; 75 | 76 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 77 | instanceUpdate({ 78 | otherStateKey: true, 79 | anotherStateKey: true 80 | }); 81 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 82 | }); 83 | }); 84 | 85 | describe("for implicit/tracked keys", () => { 86 | it("is updated when previously used keys change", () => { 87 | sandbox.stub(Component.prototype, "forceUpdate"); 88 | 89 | const cxt = { 90 | state: { 91 | stateKey: "someValue", 92 | otherStateKey: "someOtherValue" 93 | }, 94 | effects: {}, 95 | subscribe: sandbox.spy() 96 | }; 97 | 98 | getInjectedEl(cxt, null, true); 99 | const instanceUpdate = cxt.subscribe.args[0][0]; 100 | 101 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 102 | instanceUpdate({ 103 | otherStateKey: true, 104 | stateKey: true 105 | }); 106 | expect(Component.prototype.forceUpdate).to.have.been.calledOnce; 107 | }); 108 | 109 | it("is not updated when previously unused keys change", () => { 110 | sandbox.stub(Component.prototype, "forceUpdate"); 111 | 112 | const cxt = { 113 | state: { 114 | stateKey: "someValue", 115 | otherStateKey: "someOtherValue", 116 | anotherStateKey: "something" 117 | }, 118 | effects: {}, 119 | subscribe: sandbox.spy() 120 | }; 121 | 122 | getInjectedEl(cxt, null, true); 123 | const instanceUpdate = cxt.subscribe.args[0][0]; 124 | 125 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 126 | instanceUpdate({ 127 | otherStateKey: true, 128 | anotherStateKey: true 129 | }); 130 | expect(Component.prototype.forceUpdate).not.to.have.been.called; 131 | }); 132 | }); 133 | 134 | it("exposes state and effects to the wrapped component", () => { 135 | const cxt = { 136 | state: { 137 | stateKey: "someValue", 138 | otherStateKey: "someOtherValue", 139 | anotherStateKey: "something" 140 | }, 141 | effects: {}, 142 | subscribe: sandbox.spy() 143 | }; 144 | 145 | const el = getInjectedEl(cxt); 146 | 147 | expect(el.find(StatelessComponent).props()).to.include.keys(["state", "effects"]); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/provide.js: -------------------------------------------------------------------------------- 1 | import { default as React, Component } from "react"; 2 | 3 | import { StateContainer, graftParentState } from "./state"; 4 | import { getEffects } from "./effects"; 5 | import { contextTypes } from "./context"; 6 | import { HYDRATE } from "./common"; 7 | 8 | 9 | export class BaseStatefulComponent extends Component { 10 | getChildContext () { 11 | const parentContext = this.context.freactal || {}; 12 | 13 | // Capture container state while server-side rendering. 14 | if (parentContext.captureState) { 15 | parentContext.captureState(this.stateContainer.state); 16 | } 17 | 18 | const localContext = this.buildContext(); 19 | this.childContext = Object.assign({}, parentContext, localContext); 20 | 21 | // Provide context for sub-component state re-hydration. 22 | if (this.stateContainer.state[HYDRATE]) { 23 | this.childContext.getNextContainerState = this.stateContainer.state[HYDRATE]; 24 | delete this.stateContainer.state[HYDRATE]; 25 | } 26 | 27 | return { 28 | freactal: this.childContext 29 | }; 30 | } 31 | 32 | componentDidMount () { 33 | if (this.effects.initialize) { this.effects.initialize(this.props); } 34 | this.unsubscribe = this.context.freactal ? 35 | this.context.freactal.subscribe(this.relayUpdate.bind(this)) : 36 | () => {}; 37 | } 38 | 39 | componentWillUnmount () { 40 | // this.unsubscribe may be undefined due to an error in child render 41 | if (this.unsubscribe) { 42 | this.unsubscribe(); 43 | } 44 | } 45 | 46 | subscribe (onUpdate) { 47 | const subscriberId = this.nextSubscriberId++; 48 | this.subscribers[subscriberId] = onUpdate; 49 | return () => { 50 | this.subscribers[subscriberId] = null; 51 | }; 52 | } 53 | 54 | buildContext () { 55 | const parentContext = this.context.freactal || {}; 56 | const parentKeys = parentContext.state ? Object.keys(parentContext.state) : []; 57 | 58 | return this.middleware.reduce( 59 | (memo, middlewareFn) => middlewareFn(memo), 60 | { 61 | state: graftParentState(this.stateContainer.getState(parentKeys), parentContext.state), 62 | effects: this.effects, 63 | subscribe: this.subscribe 64 | } 65 | ); 66 | } 67 | 68 | invalidateChanged (changedKeys) { 69 | const relayedChangedKeys = Object.assign({}, changedKeys); 70 | 71 | const markedKeyAsChanged = key => { 72 | relayedChangedKeys[key] = true; 73 | Object.keys(this.stateContainer.computedDependants[key] || {}).forEach(markedKeyAsChanged); 74 | }; 75 | 76 | Object.keys(changedKeys) 77 | .filter(key => changedKeys[key]) 78 | .forEach(key => { 79 | this.stateContainer.invalidateCache(key); 80 | markedKeyAsChanged(key); 81 | }); 82 | 83 | return relayedChangedKeys; 84 | } 85 | 86 | relayUpdate (changedKeys) { 87 | // When updates are relayed, the context needs to be updated here; otherwise, the state object 88 | // will refer to stale parent data when subscribers re-render. 89 | Object.assign(this.childContext, this.buildContext()); 90 | const relayedChangedKeys = this.invalidateChanged(changedKeys); 91 | return Promise.all(this.subscribers.map(subscriber => 92 | subscriber && subscriber(relayedChangedKeys) 93 | )); 94 | } 95 | 96 | pushUpdate (changedKeys) { 97 | if (Object.keys(changedKeys).length === 0) { 98 | return Promise.resolve(); 99 | } 100 | 101 | // In an SSR environment, the component will not yet have rendered, and the child 102 | // context will not yet be generated. The subscribers don't need to be notified, 103 | // as they will contain correct context on their initial render. 104 | if (!this.childContext) { return Promise.resolve(); } 105 | 106 | Object.assign(this.childContext, this.buildContext()); 107 | const relayedChangedKeys = this.invalidateChanged(changedKeys); 108 | 109 | return Promise.all(this.subscribers.map(subscriber => 110 | subscriber && subscriber(relayedChangedKeys) 111 | )); 112 | } 113 | 114 | render () { 115 | return ; 116 | } 117 | } 118 | 119 | export const provideState = opts => StatelessComponent => { 120 | const { 121 | initialState = null, 122 | effects = {}, 123 | computed = {}, 124 | middleware = [] 125 | } = opts; 126 | 127 | class StatefulComponent extends BaseStatefulComponent { 128 | constructor (...args) { 129 | super(...args); 130 | 131 | this.StatelessComponent = StatelessComponent; 132 | this.middleware = middleware; 133 | 134 | this.stateContainer = new StateContainer( 135 | initialState && initialState(this.props, this.context) || Object.create(null), 136 | computed, 137 | this.pushUpdate.bind(this) 138 | ); 139 | this.getState = this.stateContainer.getState.bind(this.stateContainer); 140 | 141 | const parentContext = this.context.freactal || {}; 142 | this.effects = getEffects(this.stateContainer, effects, parentContext.effects); 143 | 144 | this.computed = computed; 145 | 146 | this.subscribe = this.subscribe.bind(this); 147 | this.nextSubscriberId = 0; 148 | this.subscribers = []; 149 | } 150 | } 151 | 152 | StatefulComponent.childContextTypes = contextTypes; 153 | StatefulComponent.contextTypes = contextTypes; 154 | 155 | // This provides a low-effort way to get at a StatefulComponent instance for testing purposes. 156 | if (!StatelessComponent) { 157 | return new StatefulComponent(null, {}); 158 | } 159 | 160 | return StatefulComponent; 161 | }; 162 | -------------------------------------------------------------------------------- /spec/unit/provide.spec.js: -------------------------------------------------------------------------------- 1 | import { default as React } from "react"; 2 | import { mount } from "enzyme"; 3 | 4 | import { provideState, BaseStatefulComponent } from "freactal/lib/provide"; 5 | import { contextTypes } from "freactal/lib/context"; 6 | 7 | 8 | const ChildContextComponent = () =>
; 9 | const StatelessComponent = (props, context) => ; 10 | StatelessComponent.contextTypes = contextTypes; 11 | 12 | const getStateful = (opts = {}) => provideState(opts)(StatelessComponent); 13 | const findChildContext = el => el.find(ChildContextComponent).props(); 14 | 15 | 16 | describe("state provider", () => { 17 | it("passes down props to its wrapped component", () => { 18 | const Stateful = getStateful(); 19 | const el = mount(); 20 | const stateless = el.find(StatelessComponent); 21 | expect(stateless).to.have.props({ 22 | a: "a", 23 | b: "b" 24 | }); 25 | }); 26 | 27 | describe("upon mounting", () => { 28 | it("invokes its `initialize` effect", () => { 29 | return new Promise(resolve => { 30 | const Stateful = getStateful({ 31 | effects: { initialize: resolve } 32 | }); 33 | mount(); 34 | }); 35 | }); 36 | 37 | it("subscribes to updates further up the tree", () => { 38 | const relayUpdate = sandbox.stub(BaseStatefulComponent.prototype, "relayUpdate"); 39 | const context = { 40 | freactal: { 41 | subscribe: sinon.spy() 42 | } 43 | }; 44 | const Stateful = getStateful(); 45 | mount(, { context }); 46 | 47 | expect(context.freactal.subscribe).to.have.been.calledOnce; 48 | const subscribeArg = context.freactal.subscribe.args[0][0]; 49 | expect(relayUpdate).not.to.have.been.called; 50 | subscribeArg({ changed: "keys go here" }); 51 | expect(relayUpdate) 52 | .to.have.been.calledOnce.and 53 | .to.have.been.calledWith({ changed: "keys go here" }); 54 | }); 55 | }); 56 | 57 | describe("context", () => { 58 | it("preserves parent freactal context where no conflicts exist", () => { 59 | const context = { 60 | freactal: { 61 | subscribe: sinon.spy(), 62 | some: "value" 63 | } 64 | }; 65 | 66 | const Stateful = getStateful(); 67 | const el = mount(, { context }); 68 | const childContext = findChildContext(el); 69 | 70 | expect(childContext).to.have.property("freactal"); 71 | expect(childContext.freactal).to.have.property("some", "value"); 72 | }); 73 | 74 | it("captures SSR state when requested", () => { 75 | const context = { 76 | freactal: { 77 | subscribe: sinon.spy(), 78 | captureState: sinon.spy() 79 | } 80 | }; 81 | 82 | const Stateful = getStateful({ initialState: () => ({ my: "state" }) }); 83 | mount(, { context }); 84 | expect(context.freactal.captureState).to.have.been.calledWith({ my: "state" }); 85 | }); 86 | 87 | it("has middleware applied", () => { 88 | const context = { 89 | freactal: { 90 | subscribe: sinon.spy() 91 | } 92 | }; 93 | 94 | const Stateful = getStateful({ 95 | middleware: [ 96 | cxt => Object.assign(cxt, { changed: "context" }) 97 | ] 98 | }); 99 | const el = mount(, { context }); 100 | const childContext = findChildContext(el); 101 | 102 | expect(childContext).to.have.property("freactal"); 103 | expect(childContext.freactal).to.have.property("changed", "context"); 104 | }); 105 | 106 | it("has state attached", () => { 107 | const context = { 108 | freactal: { 109 | subscribe: sinon.spy() 110 | } 111 | }; 112 | 113 | const Stateful = getStateful({ 114 | initialState: () => ({ my: "state" }) 115 | }); 116 | const el = mount(, { context }); 117 | const childContext = findChildContext(el); 118 | 119 | expect(childContext).to.have.property("freactal"); 120 | expect(childContext.freactal) 121 | .to.have.property("state").and 122 | /* expect state */.to.have.property("my", "state"); 123 | }); 124 | 125 | it("has effects attached", () => { 126 | const context = { 127 | freactal: { 128 | subscribe: sinon.spy() 129 | } 130 | }; 131 | 132 | const effectSpy = sinon.spy(); 133 | const Stateful = getStateful({ 134 | effects: { 135 | doThing: () => state => (effectSpy(), state) 136 | } 137 | }); 138 | const el = mount(, { context }); 139 | const childContext = findChildContext(el); 140 | 141 | expect(childContext).to.have.property("freactal"); 142 | expect(childContext.freactal) 143 | .to.have.property("effects").and 144 | /* expect effects */.to.have.property("doThing"); 145 | 146 | return childContext.freactal.effects.doThing().then(() => { 147 | expect(effectSpy).to.have.been.calledOnce; 148 | }); 149 | }); 150 | 151 | it("allows children to subscribe to changes", () => { 152 | const subscribe = sandbox.stub(BaseStatefulComponent.prototype, "subscribe"); 153 | const Stateful = getStateful(); 154 | const el = mount(); 155 | const childContext = findChildContext(el); 156 | 157 | expect(childContext).to.have.property("freactal"); 158 | expect(childContext.freactal).to.have.property("subscribe"); 159 | 160 | expect(subscribe).not.to.have.been.called; 161 | childContext.freactal.subscribe(); 162 | expect(subscribe).to.have.been.calledOnce; 163 | }); 164 | }); 165 | 166 | describe("updates", () => { 167 | it("can be subscribed to", () => { 168 | const Stateful = getStateful(); 169 | const instance = mount().instance(); 170 | const onUpdate = sinon.spy(); 171 | 172 | expect(instance.subscribers).to.have.length(0); 173 | const unsubscribe = instance.subscribe(onUpdate); 174 | expect(instance.subscribers).to.have.length(1); 175 | 176 | instance.subscribers[0](); 177 | expect(onUpdate).to.have.been.calledOnce; 178 | 179 | unsubscribe(); 180 | expect(instance.subscribers.filter(x => x)).to.have.length(0); 181 | }); 182 | 183 | describe("that are local", () => { 184 | it("occur asynchronously via a Promise", () => { 185 | const Stateful = getStateful(); 186 | const instance = mount().instance(); 187 | const p = instance.pushUpdate({}); 188 | 189 | expect(typeof p.then).to.equal("function"); 190 | 191 | return p; 192 | }); 193 | 194 | it("do not occur if no state keys have changed", () => { 195 | const Stateful = getStateful(); 196 | const instance = mount().instance(); 197 | const subscriber = sinon.spy(); 198 | instance.subscribers = [subscriber]; 199 | 200 | return instance.pushUpdate({}).then(() => { 201 | expect(subscriber).not.to.have.been.called; 202 | }); 203 | }); 204 | 205 | it("result in a local freactal context update", () => { 206 | sandbox.stub(BaseStatefulComponent.prototype, "invalidateChanged"); 207 | sandbox.stub(BaseStatefulComponent.prototype, "buildContext") 208 | .returns({ some: "new context" }); 209 | 210 | const Stateful = getStateful(); 211 | const instance = mount().instance(); 212 | 213 | return instance.pushUpdate({}).then(() => { 214 | expect(instance.childContext).to.have.property("some", "new context"); 215 | }); 216 | }); 217 | 218 | it("invalidate the changed keys", () => { 219 | const changedKeys = { someChangedKeys: true }; 220 | 221 | // eslint-disable-next-line max-len 222 | const invalidateChanged = sandbox.stub(BaseStatefulComponent.prototype, "invalidateChanged"); 223 | sandbox.stub(BaseStatefulComponent.prototype, "buildContext"); 224 | 225 | const Stateful = getStateful(); 226 | const instance = mount().instance(); 227 | 228 | return instance.pushUpdate(changedKeys).then(() => { 229 | expect(invalidateChanged).to.have.been.calledWith(changedKeys); 230 | }); 231 | }); 232 | 233 | it("are pushed to local subscribers", () => { 234 | const changedKeys = { someChangedKeys: true }; 235 | const relayedChangedKeys = { 236 | someChangedKeys: true, 237 | withSomeLocalKeys: true 238 | }; 239 | 240 | sandbox.stub(BaseStatefulComponent.prototype, "invalidateChanged") 241 | .returns(relayedChangedKeys); 242 | sandbox.stub(BaseStatefulComponent.prototype, "buildContext"); 243 | 244 | const Stateful = getStateful(); 245 | const instance = mount().instance(); 246 | const subscriber = sinon.spy(); 247 | instance.subscribers = [subscriber]; 248 | 249 | return instance.pushUpdate(changedKeys).then(() => { 250 | expect(subscriber).to.have.been.calledWith(relayedChangedKeys); 251 | }); 252 | }); 253 | }); 254 | 255 | describe("that are relayed", () => { 256 | it("invalidate the changed keys", () => { 257 | const invalidateChanged = sandbox.stub( 258 | BaseStatefulComponent.prototype, 259 | "invalidateChanged" 260 | ); 261 | const Stateful = getStateful(); 262 | const instance = mount().instance(); 263 | 264 | instance.relayUpdate({ 265 | a: "few", 266 | changed: "keys" 267 | }); 268 | 269 | expect(invalidateChanged).to.have.been.calledWith({ 270 | a: "few", 271 | changed: "keys" 272 | }); 273 | }); 274 | 275 | it("are relayed to local subscribers", () => { 276 | sandbox.stub( 277 | BaseStatefulComponent.prototype, 278 | "invalidateChanged" 279 | ).returns({ 280 | a: "few", 281 | changed: "keys", 282 | and: "one extra computed key" 283 | }); 284 | const subscriber = sinon.stub(); 285 | 286 | const Stateful = getStateful(); 287 | const instance = mount().instance(); 288 | instance.subscribers = [subscriber]; 289 | 290 | instance.relayUpdate({ 291 | a: "few", 292 | changed: "keys" 293 | }); 294 | 295 | expect(subscriber).to.have.been.calledWith({ 296 | a: "few", 297 | changed: "keys", 298 | and: "one extra computed key" 299 | }); 300 | }); 301 | }); 302 | }); 303 | 304 | describe("cache", () => { 305 | describe("invalidation", () => { 306 | it("occurs for directly dependant keys", () => { 307 | const Stateful = getStateful(); 308 | const instance = mount().instance(); 309 | sandbox.stub(instance.stateContainer, "invalidateCache"); 310 | 311 | instance.stateContainer.computedDependants.stateKey = { computedKey: true }; 312 | 313 | const relayedChangedKeys = instance.invalidateChanged({ stateKey: true }); 314 | 315 | expect(instance.stateContainer.invalidateCache).to.have.been.calledWith("stateKey"); 316 | expect(relayedChangedKeys).to.deep.equal({ 317 | stateKey: true, 318 | computedKey: true 319 | }); 320 | }); 321 | 322 | it("occurs for indirectly dependant keys", () => { 323 | const Stateful = getStateful(); 324 | const instance = mount().instance(); 325 | sandbox.stub(instance.stateContainer, "invalidateCache"); 326 | 327 | instance.stateContainer.computedDependants.stateKey = { computedKey: true }; 328 | instance.stateContainer.computedDependants.computedKey = { doubleComputedKey: true }; 329 | 330 | const relayedChangedKeys = instance.invalidateChanged({ stateKey: true }); 331 | 332 | expect(instance.stateContainer.invalidateCache).to.have.been.calledWith("stateKey"); 333 | expect(relayedChangedKeys).to.deep.equal({ 334 | stateKey: true, 335 | computedKey: true, 336 | doubleComputedKey: true 337 | }); 338 | }); 339 | }); 340 | }); 341 | }); 342 | -------------------------------------------------------------------------------- /spec/unit/state.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | import { graftParentState, StateContainer } from "freactal/lib/state"; 3 | 4 | 5 | describe("state", () => { 6 | describe("graftParentState", () => { 7 | let parent; 8 | let parentPropSpy; 9 | let child; 10 | let childPropSpy; 11 | 12 | beforeEach(() => { 13 | parent = { b: "parentB", c: "parentC", d: "parentD" }; 14 | parentPropSpy = sinon.spy(); 15 | Object.defineProperty(parent, "parentProp", { 16 | enumerable: true, 17 | get () { 18 | parentPropSpy(); 19 | return "parentProp"; 20 | } 21 | }); 22 | 23 | child = { d: "childD", e: "childE", f: "childF" }; 24 | childPropSpy = sinon.spy(); 25 | Object.defineProperty(child, "childProp", { 26 | enumerable: true, 27 | get () { 28 | childPropSpy(); 29 | return "childProp"; 30 | } 31 | }); 32 | }); 33 | 34 | it("returns state if no parent-state is provided", () => { 35 | const childKeysBefore = Object.keys(child); 36 | graftParentState(child, undefined); 37 | const childKeysAfter = Object.keys(child); 38 | expect(childKeysAfter).to.deep.equal(childKeysBefore); 39 | }); 40 | 41 | it("returns an object including all child keys", () => { 42 | graftParentState(child, parent); 43 | const childKeys = Object.keys(child); 44 | expect(childKeys) 45 | .to.include("childProp").and 46 | .to.include("d").and 47 | .to.include("e").and 48 | .to.include("f"); 49 | }); 50 | 51 | it("returns an object including all parent keys", () => { 52 | graftParentState(child, parent); 53 | const childKeys = Object.keys(child); 54 | expect(childKeys) 55 | .to.include("parentProp").and 56 | .to.include("b").and 57 | .to.include("c").and 58 | .to.include("d"); 59 | 60 | }); 61 | 62 | it("only retrieves parent state when the grafted property is accessed", () => { 63 | graftParentState(child, parent); 64 | expect(parentPropSpy).not.to.have.been.called; 65 | expect(child.parentProp).to.equal("parentProp"); 66 | expect(parentPropSpy).to.have.been.calledOnce; 67 | }); 68 | 69 | it("returns the child's state value if key is defined on child and parent", () => { 70 | graftParentState(child, parent); 71 | expect(child.d).to.equal("childD"); 72 | }); 73 | }); 74 | 75 | describe("StateContainer", () => { 76 | it("accepts an initial state", () => { 77 | const initialState = {}; 78 | const container = new StateContainer(initialState, {}, () => {}); 79 | expect(container.state).to.equal(initialState); 80 | }); 81 | 82 | describe("getState", () => { 83 | it("includes primitive state values", () => { 84 | const initialState = { local: "value" }; 85 | const container = new StateContainer(initialState, {}, () => {}); 86 | expect(Object.keys(container.getState())).to.include("local"); 87 | }); 88 | 89 | it("includes computed state values", () => { 90 | const initialState = { local: "value" }; 91 | const computed = { compound: ({ local }) => `${local}!` }; 92 | const container = new StateContainer(initialState, computed, () => {}); 93 | expect(Object.keys(container.getState())).to.include("compound"); 94 | }); 95 | }); 96 | 97 | describe("computed state values", () => { 98 | it("creates real computed key/value pairs for each key/value in the definition", () => { 99 | const initialState = {}; 100 | const computed = { 101 | a: () => "", 102 | b: () => "", 103 | c: () => "" 104 | }; 105 | const container = new StateContainer(initialState, computed, () => {}); 106 | 107 | const state = container.getState([]); 108 | expect(Object.keys(computed)).to.deep.equal(Object.keys(state)); 109 | }); 110 | 111 | it("can access local state values", () => { 112 | const initialState = { local: "value" }; 113 | const computed = { 114 | localPlusPlus: ({ local }) => `${local}-${local}` 115 | }; 116 | const container = new StateContainer(initialState, computed, () => {}); 117 | 118 | const state = container.getState([]); 119 | expect(state.localPlusPlus).to.equal("value-value"); 120 | }); 121 | 122 | it("can access parent state values", () => { 123 | const initialState = {}; 124 | const computed = { 125 | parentPlusPlus: ({ parent }) => `${parent}-${parent}` 126 | }; 127 | const container = new StateContainer(initialState, computed, () => {}); 128 | 129 | const state = container.getState(["parent"]); 130 | state.parent = "parentValue"; 131 | expect(state.parentPlusPlus).to.equal("parentValue-parentValue"); 132 | }); 133 | 134 | it("can combine local and parent state values", () => { 135 | const initialState = { local: "value" }; 136 | const computed = { 137 | localAndParent: ({ local, parent }) => `${local}-${parent}` 138 | }; 139 | const container = new StateContainer(initialState, computed, () => {}); 140 | 141 | const state = container.getState(["parent"]); 142 | state.parent = "parentValue"; 143 | expect(state.localAndParent).to.equal("value-parentValue"); 144 | }); 145 | 146 | it("track their local dependencies", () => { 147 | const initialState = { local: "value" }; 148 | const computed = { 149 | localPlusPlus: ({ local }) => `${local}-${local}` 150 | }; 151 | const container = new StateContainer(initialState, computed, () => {}); 152 | 153 | const state = container.getState([]); 154 | expect(container.computedDependants).not.to.have.property("local"); 155 | state.localPlusPlus; 156 | expect(container.computedDependants) 157 | .to.have.property("local").and 158 | .to.deep.equal({ localPlusPlus: true }); 159 | }); 160 | 161 | it("track their parent dependencies", () => { 162 | const initialState = {}; 163 | const computed = { 164 | parentPlusPlus: ({ parent }) => `${parent}-${parent}` 165 | }; 166 | const container = new StateContainer(initialState, computed, () => {}); 167 | 168 | const state = container.getState(["parent"]); 169 | state.parent = "parentValue"; 170 | 171 | expect(container.computedDependants).not.to.have.property("parent"); 172 | state.parentPlusPlus; 173 | expect(container.computedDependants) 174 | .to.have.property("parent").and 175 | .to.deep.equal({ parentPlusPlus: true }); 176 | }); 177 | 178 | it("are cached from one `get` to the next", () => { 179 | const initialState = {}; 180 | const computed = { 181 | localPlusPlus: ({ local }) => `${local}-${local}` 182 | }; 183 | const container = new StateContainer(initialState, computed, () => {}); 184 | 185 | const state = container.getState(["local"]); 186 | 187 | let first = true; 188 | const localSpy = sinon.spy(); 189 | Object.defineProperty(state, "local", { 190 | enumerable: true, 191 | get () { 192 | localSpy(); 193 | if (first) { 194 | first = false; 195 | return "first"; 196 | } 197 | return "not-first"; 198 | } 199 | }); 200 | 201 | expect(localSpy).not.to.have.been.called; 202 | expect(state.localPlusPlus).to.equal("first-first"); 203 | expect(localSpy).to.have.been.calledOnce; 204 | expect(container.cachedState).to.have.property("localPlusPlus", "first-first"); 205 | expect(state.localPlusPlus).to.equal("first-first"); 206 | expect(localSpy).to.have.been.calledOnce; 207 | }); 208 | 209 | it("are recalculated when their cache is directly invalidated", () => { 210 | const initialState = {}; 211 | const computed = { 212 | localPlusPlus: ({ local }) => `${local}-${local}` 213 | }; 214 | const container = new StateContainer(initialState, computed, () => {}); 215 | 216 | const state = container.getState(["local"]); 217 | 218 | let first = true; 219 | const localSpy = sinon.spy(); 220 | Object.defineProperty(state, "local", { 221 | enumerable: true, 222 | get () { 223 | localSpy(); 224 | if (first) { 225 | first = false; 226 | return "first"; 227 | } 228 | return "not-first"; 229 | } 230 | }); 231 | 232 | expect(localSpy).not.to.have.been.called; 233 | expect(state.localPlusPlus).to.equal("first-first"); 234 | expect(localSpy).to.have.been.calledOnce; 235 | 236 | container.invalidateCache("local"); 237 | expect(container.cachedState).not.to.have.property("localPlusPlus"); 238 | 239 | expect(state.localPlusPlus).to.equal("not-first-not-first"); 240 | expect(localSpy).to.have.been.calledTwice; 241 | }); 242 | 243 | it("are recalculated when their cache is indirectly invalidated", () => { 244 | const initialState = {}; 245 | const computed = { 246 | intermediate: ({ localPlusPlus }) => `${localPlusPlus}!`, 247 | localPlusPlus: ({ local }) => `${local}-${local}` 248 | }; 249 | const container = new StateContainer(initialState, computed, () => {}); 250 | 251 | const state = container.getState(["local"]); 252 | 253 | let first = true; 254 | const localSpy = sinon.spy(); 255 | Object.defineProperty(state, "local", { 256 | enumerable: true, 257 | get () { 258 | localSpy(); 259 | if (first) { 260 | first = false; 261 | return "first"; 262 | } 263 | return "not-first"; 264 | } 265 | }); 266 | 267 | expect(localSpy).not.to.have.been.called; 268 | expect(state.intermediate).to.equal("first-first!"); 269 | expect(localSpy).to.have.been.calledOnce; 270 | 271 | container.invalidateCache("local"); 272 | expect(container.cachedState).not.to.have.property("intermediate"); 273 | 274 | expect(state.intermediate).to.equal("not-first-not-first!"); 275 | expect(localSpy).to.have.been.calledTwice; 276 | }); 277 | }); 278 | 279 | describe("setState", () => { 280 | it("updates the internal state for changed values", () => { 281 | const initialState = { local: "value" }; 282 | const computed = { 283 | compound: ({ local }) => `${local}!` 284 | }; 285 | const container = new StateContainer(initialState, computed, () => {}); 286 | 287 | expect(container.state).to.have.property("local", "value"); 288 | container.setState({ local: "newValue" }); 289 | expect(container.state).to.have.property("local", "newValue"); 290 | }); 291 | 292 | it("invalidates cache for first-level dependencies of changed values", () => { 293 | const initialState = { local: "value" }; 294 | const computed = { 295 | compound: ({ local }) => `${local}!` 296 | }; 297 | const container = new StateContainer(initialState, computed, () => {}); 298 | 299 | container.getState().compound; 300 | expect(container.cachedState).to.have.property("compound", "value!"); 301 | container.setState({ local: "newValue" }); 302 | expect(container.cachedState).not.to.have.property("compound"); 303 | }); 304 | 305 | it("invalidates cache for second-level dependencies of changes values", () => { 306 | const initialState = { local: "value" }; 307 | const computed = { 308 | compound: ({ local }) => `${local}!`, 309 | veryCompound: ({ compound }) => `${compound}!!` 310 | }; 311 | const container = new StateContainer(initialState, computed, () => {}); 312 | 313 | container.getState().veryCompound; 314 | expect(container.cachedState).to.have.property("veryCompound", "value!!!"); 315 | container.setState({ local: "newValue" }); 316 | expect(container.cachedState).not.to.have.property("veryCompound"); 317 | }); 318 | 319 | it("notifies the StateContainer's consumer after updates are made", () => { 320 | const initialState = { 321 | a: "a", 322 | b: "b" 323 | }; 324 | const stateChanged = sinon.spy(); 325 | const container = new StateContainer(initialState, {}, stateChanged); 326 | 327 | const update = toMerge => { 328 | const state = container.getState(); 329 | container.setState(Object.assign({}, state, toMerge)); 330 | }; 331 | 332 | expect(stateChanged).not.to.have.been.called; 333 | 334 | update({ a: "A" }); 335 | expect(stateChanged.getCall(0).args[0]).to.deep.equal({ a: true }); 336 | 337 | update({ a: "A", b: "B" }); 338 | expect(stateChanged.getCall(1).args[0]).to.deep.equal({ b: true }); 339 | 340 | update({ a: "AAA", b: "BBB" }); 341 | expect(stateChanged.getCall(2).args[0]).to.deep.equal({ a: true, b: true }); 342 | 343 | update({ a: "AAA", b: "BBB" }); 344 | expect(stateChanged.getCall(3).args[0]).to.deep.equal({}); 345 | }); 346 | }); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fr(e)actal 2 | 3 | [![CircleCI](https://circleci.com/gh/FormidableLabs/freactal.svg?style=svg)](https://circleci.com/gh/FormidableLabs/freactal) 4 | 5 | `freactal` is a composable state management library for React. 6 | 7 | The library grew from the idea that state should be just as flexible as your React code; the state containers you build with `freactal` are just components, and you can compose them however you'd like. In this way, it attempts to address the often exponential relationship between application size and complexity in growing projects. 8 | 9 | Like Flux and React in general, `freactal` builds on the principle of unidirectional flow of data. However, it does so in a way that feels idiomatic to ES2015+ and doesn't get in your way. 10 | 11 | When building an application, it can replace [`redux`](https://redux.js.org), [`MobX`](https://mobx.js.org), [`reselect`](https://github.com/reactjs/reselect), [`redux-loop`](https://github.com/redux-loop/redux-loop), [`redux-thunk`](https://github.com/gaearon/redux-thunk), [`redux-saga`](https://github.com/redux-saga/redux-saga), `[fill-in-the-blank sub-app composition technique]`, and potentially [`recompose`](https://github.com/acdlite/recompose), depending on how you're using it. 12 | 13 | Its design philosophy aligns closely with the [Zen of Python](https://www.python.org/dev/peps/pep-0020/): 14 | 15 | ``` 16 | Beautiful is better than ugly. 17 | Explicit is better than implicit. 18 | Simple is better than complex. 19 | Complex is better than complicated. 20 | Flat is better than nested. 21 | Sparse is better than dense. 22 | Readability counts. 23 | ``` 24 | 25 |

26 | 27 | 28 | ## Submitting Bugs 29 | 30 | If you encounter an issue with freactal, please [look to see](https://github.com/FormidableLabs/freactal/issues) if the issue has already been reported. If it has not, please [open a new issue](https://github.com/FormidableLabs/freactal/issues/new). 31 | 32 | When submitting a bug report, make sure to include a repro. The best way to do this, is to fork the [freactal-sketch sandbox](https://codesandbox.io/s/github/divmain/freactal-sketch/tree/master/), modify it so that the bug is observable, and include a link in your bug report. 33 | 34 | 35 |

36 | 37 | 38 | ## Table of Contents 39 | 40 | - [Guide](#guide) 41 | - [Containing state](#containing-state) 42 | - [Accessing state from a child component](#accessing-state-from-a-child-component) 43 | - [Transforming state](#transforming-state) 44 | - [Transforming state (cont.)](#transforming-state-cont) 45 | - [Intermediate state](#intermediate-state) 46 | - [Effect arguments](#effect-arguments) 47 | - [Computed state values](#computed-state-values) 48 | - [Composing multiple state containers](#composing-multiple-state-containers) 49 | - [Testing](#testing) 50 | - [Stateless functional components](#stateless-functional-components) 51 | - [State and effects](#state-and-effects) 52 | - [Conclusion](#conclusion) 53 | - [API Documentation](#api-documentation) 54 | - [`provideState`](#providestate) 55 | - [`initialState`](#initialstate) 56 | - [`effects`](#effects) 57 | - [`initialize`](#initialize) 58 | - [`computed`](#computed) 59 | - [`middleware`](#middleware) 60 | - [`injectState`](#injectstate) 61 | - [`hydrate` and `initialize`](#hydrate-and-initialize) 62 | - [Helper functions](#helper-functions) 63 | - [`update`](#update) 64 | - ['mergeIntoState'](#mergeintostate) 65 | - [Server-side Rendering](#server-side-rendering) 66 | - [with `React#renderToString`](#with-reactrendertostring) 67 | - [with Rapscallion](#with-rapscallion) 68 | - [Hydrate state on the client](#hydrate-state-on-the-client) 69 | 70 | 71 |

72 | 73 | 74 | ## Guide 75 | 76 | This guide is intended to get you familiar with the `freactal` way of doing things. If you're looking for something specific, take a look at the [API Documentation](#api-documentation). If you're just starting out with `freactal`, read on! 77 | 78 | 79 | ### Containing state 80 | 81 | Most state management solutions for React put all state in one place. `freactal` doesn't suffer from that constraint, but it is a good place to start. So let's see what that might look like. 82 | 83 | ```javascript 84 | import { provideState } from "freactal"; 85 | 86 | const wrapComponentWithState = provideState({ 87 | initialState: () => ({ counter: 0 }) 88 | }); 89 | ``` 90 | 91 | In the above example, we define a new state container type using `provideState`, and provide it an argument. You can think about the arguments passed to `provideState` as the schema for your state container; we'll get more familiar with the other possible arguments later in the guide. 92 | 93 | The `initialState` function is invoked whenever the component that it is wrapping is instantiated. But so far, our state container is not wrapping anything, so let's expand our example a bit. 94 | 95 | ```javascript 96 | import React, { Component } from "react"; 97 | import { render } from "react-dom"; 98 | 99 | const Parent = ({ state }) => ( 100 |
101 | { `Our counter is at: ${state.counter}` } 102 |
103 | ); 104 | 105 | render(, document.getElementById("root")); 106 | ``` 107 | 108 | That's a _very_ basic React app. Let's see what it looks like to add some very basic state to that application. 109 | 110 | ```javascript 111 | import React, { Component } from "react"; 112 | import { render } from "react-dom"; 113 | import { provideState } from "freactal"; 114 | 115 | const wrapComponentWithState = provideState({ 116 | initialState: () => ({ counter: 0 }) 117 | }); 118 | 119 | const Parent = wrapComponentWithState(({ state }) => ( 120 |
121 | { `Our counter is at: ${state.counter}` } 122 |
123 | )); 124 | 125 | render(, document.getElementById("root")); 126 | ``` 127 | 128 | Alright, we're getting close. But we're missing one important piece: `injectState`. 129 | 130 | Like `provideState`, `injectState` is a component wrapper. It links your application component with the state that it has access to. 131 | 132 | It may not be readily apparent to you why `injectState` is necessary, so let's make it clear what each `freactal` function is doing. With `provideState`, you define a state template that can then be applied to any component. Once applied, that component will act as a "headquarters" for a piece of state and the effects that transform it (more on that later). If you had a reason, the same template could be applied to multiple components, and they'd each have their own state based on the template you defined. 133 | 134 | But that only _tracks_ state. It doesn't make that state accessible to the developer. That's what `injectState` is for. 135 | 136 | In early versions of `freactal`, the state was directly accessible to the component that `provideState` wrapped. However, that meant that whenever a state change occurred, the entire tree would need to re-render. `injectState` intelligently tracks the pieces of state that you actually access, and a re-render only occurs when _those_ pieces of state undergo a change. 137 | 138 | Alright, so let's finalize our example with all the pieces in play. 139 | 140 | ```javascript 141 | import React, { Component } from "react"; 142 | import { render } from "react-dom"; 143 | import { provideState, injectState } from "freactal"; 144 | 145 | const wrapComponentWithState = provideState({ 146 | initialState: () => ({ counter: 0 }) 147 | }); 148 | 149 | const Parent = wrapComponentWithState(injectState(({ state }) => ( 150 |
151 | { `Our counter is at: ${state.counter}` } 152 |
153 | ))); 154 | 155 | render(, document.getElementById("root")); 156 | ``` 157 | 158 | That'll work just fine! 159 | 160 | 161 |

162 | 163 | 164 | ### Accessing state from a child component 165 | 166 | As was mentioned above, the `provideState`-wrapped component isn't really the one that provides access to state. That's `injectState`'s job. So what would stop you from injecting state into a child component that isn't containing state itself? The answer is nothing! 167 | 168 | Let's modify the example so that we're injecting state into a child component. 169 | 170 | ```javascript 171 | import React, { Component } from "react"; 172 | import { render } from "react-dom"; 173 | import { provideState, injectState } from "freactal"; 174 | 175 | 176 | const Child = injectState(({ state }) => ( 177 |
178 | { `Our counter is at: ${state.counter}` } 179 |
180 | )); 181 | 182 | const wrapComponentWithState = provideState({ 183 | initialState: () => ({ counter: 0 }) 184 | }); 185 | 186 | const Parent = wrapComponentWithState(({ state }) => ( 187 | 188 | )); 189 | 190 | 191 | render(, document.getElementById("root")); 192 | ``` 193 | 194 | Let's review what's going on here. 195 | 196 | 1. Using `provideState`, we define a state-container template intended to store a single piece of state: the `counter`. 197 | 2. That template is applied to the `Parent` component. 198 | 3. When the `Parent` is rendered, we see that it references a `Child` component. 199 | 4. That `Child` component is wrapped with `injectState`. 200 | 5. Because `Child` is contained within the subtree where `Parent` is the root node, it has access to the `Parent` component's state. 201 | 202 | We could insert another component at the end, and `injectState` into the `GrandChild` component, and it would work the same. 203 | 204 | 205 |

206 | 207 | 208 | ### Transforming state 209 | 210 | Alright, so we know how to setup state containers, give them an initial state, and consume that state from child components. But all of this is not very useful if state is never updated. That's where effects come in. 211 | 212 | Effects are the one and only way to change `freactal` state in your application. These effects are defined as part of your state container template when calling `provideState`, and they can be invoked from anywhere that state has been injected (with `injectState`). 213 | 214 | Let's take a look at that first part. 215 | 216 | ```javascript 217 | const wrapComponentWithState = provideState({ 218 | initialState: () => ({ counter: 0 }), 219 | effects: { 220 | addOne: () => state => Object.assign({}, state, { counter: state.counter + 1 }) 221 | } 222 | }); 223 | ``` 224 | 225 | You might be wondering why we have that extra `() =>` right before `state =>` in the `addOne` definition. That'll be explained in the next section - for now, let's look at all the other pieces. 226 | 227 | In the above example, we've defined an effect that, when invoked, will update the `counter` in our state container by adding `1`. 228 | 229 | Since updating an element of state based on previous state (and potentially new information) is something you'll be doing often, `freactal` [provides a shorthand](#update) to make this a bit more readable: 230 | 231 | ```javascript 232 | const wrapComponentWithState = provideState({ 233 | initialState: () => ({ counter: 0 }), 234 | effects: { 235 | addOne: update(state => ({ counter: state.counter + 1 })) 236 | } 237 | }); 238 | ``` 239 | 240 | Now let's look at how you might trigger this effect: 241 | 242 | ```javascript 243 | const Child = injectState(({ state, effects }) => ( 244 |
245 | { `Our counter is at: ${state.counter}` } 246 | 247 |
248 | )); 249 | ``` 250 | 251 | Wherever your `` is in your application, the state and effects it references will be accessible, so long as the state container is somewhere further up in the tree. 252 | 253 |

254 | 255 | 256 | ### Transforming state (cont.) 257 | 258 | If you've used Redux, effects are roughly comparable to an action-reducer pair, with a couple of important differences. 259 | 260 | The first of those differences relates to asychronicity. Under the hood, `freactal` relies heavily on `Promise`s to schedule state updates. In fact, the following effects are all functionally equivalent: 261 | 262 | ```javascript 263 | addOne: () => state => Object.assign({}, state, { counter: state.counter + 1 }) 264 | /* vs */ 265 | addOne: () => Promise.resolve(state => Object.assign({}, state, { counter: state.counter + 1 })) 266 | /* vs */ 267 | addOne: () => new Promise(resolve => resolve(state => Object.assign({}, state, { counter: state.counter + 1 }))) 268 | ``` 269 | 270 | To put it explicitly, the value you provide for each key in your `effects` object is: 271 | 272 | 1. A function that takes in some arguments (we'll cover those shortly) and returns... 273 | 2. A promise that resolves to... 274 | 3. A function that takes in state and returns... 275 | 4. The updated state. 276 | 277 | Step 2 can optionally be omitted, since `freactal` wraps these values in `Promise.resolve`. 278 | 279 | For most developers, this pattern is probably the least familiar of those that `freactal` relies upon. But it allows for some powerful and expressive state transitions with basically no boilerplate. 280 | 281 | For example, any number of things can occur between the time that an effect is invoked and the time that the state is updated. These "things" might include doing calculations, or talking to an API, or integrating with some other JS library. 282 | 283 | So, you might define the following effect: 284 | 285 | ```javascript 286 | updatePosts: () => fetch("/api/posts") 287 | .then(result => result.json()) 288 | .then(({ posts }) => state => Object.assign({}, state, { posts })) 289 | ``` 290 | 291 | In other words, any action that your application might take, that ultimately _could_ result in a state change can be simply expressed as an effect. Not only that, but this pattern also allows for effects and UI components to be tested with clean separation. 292 | 293 | And, perhaps most importantly, this pattern allows for intermediate state. 294 | 295 | 296 |

297 | 298 | 299 | ### Intermediate state 300 | 301 | So far, we haven't see any arguments to the first, outer-most function in our effect definitions. In simple scenarios, this outer-function may seem unnecessary, as in the illustration above. 302 | 303 | But what about cases where you want state to be updated part-way through an operation? You _could_ put all this logic in your UI code, and invoke effects from there multiple times. But that's not ideal for a number of reasons: 304 | 305 | 1. A single effect might be invoked from multiple places in your application. 306 | 2. The code that influences how state might be transformed is now living in multiple places. 307 | 3. It is much harder to test. 308 | 309 | Fundamentally, the problem is that this pattern violates the principle of separation of concerns. 310 | 311 | So, what's the alternative? 312 | 313 | Well, we've already defined an effect as a function that, when invoked, will resolve to another function that transforms state. Why couldn't we re-use this pattern to represent this "part-way" (or intermediate) state? The answer is: nothing is stopping us! 314 | 315 | The first argument passed to an effect in the outer function is the same `effects` object that is exposed to components where state has been injected. And these effects can be invoked in the same way. Even more importantly, because effects always resolve to a `Promise`, we can wait for an intermediate state transition to complete before continuing with our original state transition. 316 | 317 | That might be a lot to take in, so let's look at an example: 318 | 319 | ```javascript 320 | const wrapComponentWithState = provideState({ 321 | initialState: () => ({ 322 | posts: null, 323 | postsPending: false 324 | }), 325 | effects: { 326 | setPostsPending: update((state, postsPending) => ({ postsPending })), 327 | getPosts: effects => effects.setPostsPending(true) 328 | .then(() => fetch("/api/posts")) 329 | .then(result => result.json()) 330 | .then(({ posts }) => effects.setPostsPending(false).then(() => posts)) 331 | .then(posts => state => Object.assign({}, state, { posts })) 332 | } 333 | }); 334 | ``` 335 | 336 | There's a lot going on there, so let's go through it piece by piece. 337 | 338 | - The initial state is set with two keys, `posts` and `postsPending`. 339 | + `posts` will eventually contain an array of blog posts or something like that. 340 | + `postsPending` is a flag that, when `true`, indicates that we are currently fetching the `posts`. 341 | - Two `effects` are defined. 342 | + `setPostsPending` sets the `postsPending` flag to either `true` or `false`. 343 | + `getPosts` does a number of things: 344 | * It invokes `setPostsPending`, setting the pending flag to `true`. 345 | * It waits for the `setPostsPending` effect to complete before continuing. 346 | * It fetches some data from an API. 347 | * It parses that data into JSON. 348 | * It invokes `setPostsPending` with a value of `false`, and waits for it to complete. 349 | * It resolves to a function that updates the `posts` state value. 350 | 351 | In the above example, `setPostsPending` has a synchronous-like behavior - it immediately resolves to a state update function. But it could just as easily do something asynchronous, like make an AJAX call or interact with the IndexedDB API. 352 | 353 | And because all of this is just `Promise` composition, you can put together helper functions that give consistency to intermediate state updates. Here's an example: 354 | 355 | ```javascript 356 | const wrapWithPending = (pendingKey, cb) => effects => 357 | effects.setFlag(pendingKey, true) 358 | .then(cb) 359 | .then(value => effects.setFlag(pendingKey, false).then(() => value)); 360 | ``` 361 | 362 | Which could be consumed like so: 363 | 364 | ```javascript 365 | const wrapComponentWithState = provideState({ 366 | initialState: () => ({ 367 | posts: null, 368 | postsPending: false 369 | }), 370 | effects: { 371 | setFlag: update((state, key, value) => ({ [key]: value })), 372 | getPosts: wrapWithPending("postsPending", () => fetch("/api/posts") 373 | .then(result => result.json()) 374 | .then(({ posts }) => state => Object.assign({}, state, { posts })) 375 | ) 376 | } 377 | }); 378 | ``` 379 | 380 | 381 |

382 | 383 | 384 | ### Effect arguments 385 | 386 | But what if you want to update state with some value that you captured from the user? In Redux parlance: what about action payloads? 387 | 388 | If you were looking closely, you may have noticed we already did something like that when we invoked `setPostsPending`. 389 | 390 | Whether you are invoking an effect from your UI code or from another effect, you can pass arguments directly with the invocation. Those arguments will show up after the `effects` argument in your effect definition. 391 | 392 | Here's an example: 393 | 394 | ```javascript 395 | const wrapComponentWithState = provideState({ 396 | initialState: () => ({ thing: "val" }), 397 | effects: { 398 | setThing: (effects, newVal) => state => Object.assign({}, state, { thing: newVal }) 399 | } 400 | }); 401 | ``` 402 | 403 | And it could invoked from your component like so: 404 | 405 | ```javascript 406 | const Child = injectState(({ state, effects }) => { 407 | const onClick = () => effects.setThing("new val"); 408 | return ( 409 |
410 | { `Our "thing" value is: ${state.thing}` } 411 | 412 |
413 | ); 414 | }); 415 | ``` 416 | 417 | 418 |

419 | 420 | 421 | ### Computed state values 422 | 423 | As an application grows, it becomes increasingly important to have effective organizational tools. This is especially true for how you store and transform data. 424 | 425 | Consider the following state container: 426 | 427 | ```javascript 428 | const wrapComponentWithState = provideState({ 429 | initialState: () => ({ 430 | givenName: "Walter", 431 | familyName: "Harriman" 432 | }), 433 | effects: { 434 | setGivenName: update((state, val) => ({ givenName: val })), 435 | setFamilyName: update((state, val) => ({ familyName: val })) 436 | } 437 | }); 438 | ``` 439 | 440 | Let's say that we're implementing a component and we want to display the user's full name. We might write that component like this: 441 | 442 | ```javascript 443 | const WelcomeMessage = injectState(({ state }) => { 444 | const fullName = `${state.givenName} ${state.familyName}`; 445 | return ( 446 |
447 | {`Hi, ${fullName}, and welcome!`} 448 |
449 | ); 450 | }); 451 | ``` 452 | 453 | That seems like a pretty reasonable piece of code. But, even for a small piece of data like a full name, things can get more complex as the application grows. 454 | 455 | What if we're displaying that full name in multiple components? Should we compute it in all those places, or maybe inject state further up the tree and pass it down as a prop? That can get messy to the point where you're passing down dozens of props. 456 | 457 | What if the user is in a non-English locale, where they may not place given names before family names? We would have to remember to do that everywhere. 458 | 459 | And what if we want to derive another value off of the generated `fullName` value? What about multiple derived values, derived from other derived values? What if we're not dealing with names, but more complex data structures instead? 460 | 461 | `freactal`'s answer to this is computed values. 462 | 463 | You've probably run into something like this before. Vue.js has computed properties. MobX has computed values. Redux outsources this concern to libraries like `reselect`. Ultimately, they all serve the same function: exposing compound values to the UI based on simple state values. 464 | 465 | Here's how you define computed values in `freactal`, throwing in some of the added complexities we mentioned: 466 | 467 | ```javascript 468 | const wrapComponentWithState = provideState({ 469 | initialState: () => ({ 470 | givenName: "Walter", 471 | familyName: "Harriman", 472 | locale: "en-us" 473 | }), 474 | effects: { 475 | setGivenName: update((state, val) => ({ givenName: val })), 476 | setFamilyName: update((state, val) => ({ familyName: val })) 477 | }, 478 | computed: { 479 | fullName: ({ givenName, familyName, locale }) => startsWith(locale, "en") ? 480 | `${givenName} ${familyName}` : 481 | `${familyName} ${givenName}`, 482 | greeting: ({ fullName, locale }) => startsWith(locale, "en") ? 483 | `Hi, ${fullName}, and welcome!` : 484 | `Helló ${fullName}, és szívesen!` 485 | } 486 | }); 487 | ``` 488 | 489 | _**Note:** This is not a replacement for a proper internationalization solution like `react-intl`, and is for illustration purposes only._ 490 | 491 | Here we see two computed values, `fullName` and `greeting`. They both rely on the `locale` state value, and `greeting` actually relies upon `fullName`, whereas `fullName` relies on the given and family names. 492 | 493 | How might that be consumed? 494 | 495 | ```javascript 496 | const WelcomeMessage = injectState(({ state }) => ( 497 |
498 | {state.greeting} 499 |
500 | )); 501 | ``` 502 | 503 | In another component, we might want to just use the `fullName` value: 504 | 505 | ```javascript 506 | const Elsewhere = injectState(({ state }) => ( 507 |
508 | {`Are you sure you want to do that, ${state.fullName}?`} 509 |
510 | )); 511 | ``` 512 | 513 | Hopefully you can see that this can be a powerful tool to help you keep your code organized and readable. 514 | 515 | Here are a handful of other things that will be nice for you to know. 516 | 517 | - Computed values are generated _lazily_. This means that if the `greeting` value above is never accessed, it will never be computed. 518 | - Computed values are _cached_. Once a computed value is calculated once, a second state retrieval will return the cached value. 519 | - Cached values are _invalidated_ when dependencies change. If you were to trigger the `setGivenName` effect with a new name, the `fullName` and `greeting` values would be recomputed as soon as React re-rendered the UI. 520 | 521 | That's all you need to know to use computed values effectively! 522 | 523 | 524 |

525 | 526 | 527 | ### Composing multiple state containers 528 | 529 | We started this guide by noting that, while most React state libraries contain state in a single place, `freactal` approaches things differently. 530 | 531 | Before we dive into how that works, let's briefly consider some of the issues that arise with the centralized approach to state management: 532 | 533 | - Oftentimes, it is hard to know how to organize state-related code. Definitions for events or actions live separately from the UI that triggers them, which lives separately from functions that reduce those events into state, which also live separately from code that transforms state into more complex values. 534 | - While React components are re-usable ([see](http://www.material-ui.com/) [component](http://elemental-ui.com/) [libraries](https://github.com/brillout/awesome-react-components)), complex stateful components are a hard nut to crack. There's this fuzzy line when addressing complexity in your own code that, when crossed, means you should be using a state library vs React's own `setState`. But how do you make that work DRY across applications and team boundaries? 535 | - Sometimes you might want to compose full SPAs together in various ways, but if they need to interact on the page or share state in some way, how do you go about accomplishing this? The results here are almost universally ad-hoc. 536 | - It is an often arduous process when it comes time to refactor your application and move state-dependant components into different parts of your application. Wiring everything up can be tedious as hell. 537 | 538 | These are constraints that `freactal` aims to address. Let's take a look at a minimal example: 539 | 540 | ```javascript 541 | const Child = injectState(({ state }) => ( 542 |
543 | This is the Child. 544 | {state.fromParent} 545 | {state.fromGrandParent} 546 |
547 | )); 548 | 549 | const Parent = provideState({ 550 | initialState: () => ({ fromParent: "ParentValue" }) 551 | })(() => ( 552 |
553 | This is the Parent. 554 | 555 |
556 | )); 557 | 558 | const GrandParent = provideState({ 559 | initialState: () => ({ fromGrandParent: "GrandParentValue" }) 560 | })(() => ( 561 |
562 | This is the GrandParent. 563 | 564 |
565 | )); 566 | ``` 567 | 568 | Its important to notice here that `Child` was able to access state values from both its `Parent` and its `GrandParent`. All state keys will be accessible from the `Child`, unless there is a key conflict between `Parent` and `GrandParent` (in which case `Parent` "wins"). 569 | 570 | This pattern allows you to co-locate your code by feature, rather than by function. In other words, if you're rolling out a new feature for your application, all of that new code - UI, state, effects, etc - can go in one place, rather than scattered across your code-base. 571 | 572 | Because of this, refactoring becomes easier. Want to move a component to a different part of your application? Just move the directory and update the import from the parents. What if this component accesses parent state? If that parent is still an anscestor, you don't have to change a thing. If it's not, moving that state to a more appropriate place should be part of the refactor anyway. 573 | 574 | But one word of warning: accessing parent state can be powerful, and very useful, but it also necessarily couples the child state to the parent state. While the coupling is a "loose" coupling, it still may introduce complexity that should be carefully thought-out. 575 | 576 | One more thing. 577 | 578 | Child effects can also trigger parent effects. Let's say your UX team has indicated that, whenever an API call is in flight, a global spinner should be shown. But maybe the data is only needed in certain parts of the application. In this scenario, you could define `beginApiCall` and `completeApiCall` effects that track how many API calls are active. If above `0`, you show a spinner. These effects can be accessed by call-specific effects further down in the state hierarchy, like so: 579 | 580 | ```javascript 581 | const Child = injectState(({ state, effects }) => ( 582 |
583 | This is the Child. 584 | {state.fromParent} 585 | {state.fromGrandParent} 586 | 591 |
592 | )); 593 | 594 | const Parent = provideState({ 595 | initialState: () => ({ fromParent: "ParentValue" }), 596 | effects: { 597 | changeParentState: (effects, fromParent) => state => 598 | Object.assign({}, state, { fromParent }), 599 | changeBothStates: (effects, value) => 600 | effects.changeGrandParentState(value).then(state => 601 | Object.assign({}, state, { fromParent: value }) 602 | ) 603 | } 604 | })(() => ( 605 |
606 | This is the Parent. 607 | 608 |
609 | )); 610 | 611 | const GrandParent = provideState({ 612 | initialState: () => ({ fromGrandParent: "GrandParentValue" }), 613 | effects: { 614 | changeGrandParentState: (effects, fromGrandParent) => state => 615 | Object.assign({}, state, { fromGrandParent }) 616 | } 617 | })(() => ( 618 |
619 | This is the GrandParent. 620 | 621 |
622 | )); 623 | ``` 624 | 625 | 626 | ## Testing 627 | 628 | Before wrapping up, let's take a look at one additional benefit that `freactal` brings to the table: the ease of test-writing. 629 | 630 | If you hadn't noticed already, all of the examples we've looked at in this guide have relied upon [stateless functional components](https://hackernoon.com/react-stateless-functional-components-nine-wins-you-might-have-overlooked-997b0d933dbc). This is no coincidence - from the beginning, a primary goal of `freactal` was to encapsulate _all_ state in `freactal` state containers. That means you shouldn't need to use React's `setState` at all. 631 | 632 | **Here's the bottom line:** because _all_ state can be contained within `freactal` state containers, the rest of your application components can be ["dumb components"](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0). 633 | 634 | This approach allows you to test your state and your components completely independent from one another. 635 | 636 | Let's take a look at a simplified example from above, and then dive into how you might test this application. For the purposes of this example, I assume you're using Mocha, Chai, Sinon, sinon-chai, and Enzyme. 637 | 638 | First, our application code: 639 | 640 | ```javascript 641 | /*** app.js ***/ 642 | 643 | import { wrapComponentWithState } from "./state"; 644 | 645 | 646 | export const App = ({ state, effects }) => { 647 | const { givenName, familyName, fullName, greeting } = state; 648 | const { setGivenName, setFamilyName } = effects; 649 | 650 | const onChangeGiven = ev => setGivenName(ev.target.value); 651 | const onChangeFamily = ev => setFamilyName(ev.target.value); 652 | 653 | return ( 654 |
655 |
656 | { greeting } 657 |
658 |
659 | 660 | 661 | 662 | 663 |
664 |
665 | ); 666 | }; 667 | 668 | /* Notice that we're exporting both the unwrapped and the state-wrapped component... */ 669 | export default wrapComponentWithState(App); 670 | ``` 671 | 672 | And then our state template: 673 | 674 | ```javascript 675 | /*** state.js ***/ 676 | 677 | import { provideState, update } from "freactal"; 678 | 679 | export const wrapComponentWithState = provideState({ 680 | initialState: () => ({ 681 | givenName: "Walter", 682 | familyName: "Harriman" 683 | }), 684 | effects: { 685 | setGivenName: update((state, val) => ({ givenName: val })), 686 | setFamilyName: update((state, val) => ({ familyName: val })) 687 | }, 688 | computed: { 689 | fullName: ({ givenName, familyName }) => `${givenName} ${familyName}`, 690 | greeting: ({ fullName }) => `Hi, ${fullName}, and welcome!` 691 | } 692 | }); 693 | ``` 694 | 695 | Next, let's add a few tests! 696 | 697 | 698 | ### Stateless functional components 699 | 700 | Remember, our goal here is to test state and UI in isolation. Read through the following example to see how you might make assertions about 1) data-driven UI content, and 2) the ways in which your UI might trigger an effect. 701 | 702 | ```javascript 703 | /*** app.spec.js ***/ 704 | 705 | import { mount } from "enzyme"; 706 | // Make sure to import the _unwrapped_ component here! 707 | import { App } from "./app"; 708 | 709 | 710 | // We'll be re-using these values, so let's put it here for convenience. 711 | const state = { 712 | givenName: "Charlie", 713 | familyName: "In-the-box", 714 | fullName: "Charlie In-the-box", 715 | greeting: "Howdy there, kid!" 716 | }; 717 | 718 | describe("my app", () => { 719 | it("displays a greeting to the user", () => { 720 | // This test should be easy - all we have to do is ensure that 721 | // the string that is passed in is displayed correctly! 722 | 723 | // We're not doing anything with effects here, so let's not bother 724 | // setting them for now... 725 | const effects = {}; 726 | 727 | // First, we mount the component, providing the expected state and effects. 728 | const el = mount(); 729 | 730 | // And then we can make assertions on the output. 731 | expect(el.find("#greeting").text()).to.equal("Howdy there, kid!"); 732 | }); 733 | 734 | it("accepts changes to the given name", () => { 735 | // Next we're testing the conditions under which our component might 736 | // interact with the provided effects. 737 | const effects = { 738 | setGivenName: sinon.spy(), 739 | setFamilyName: sinon.spy() 740 | }; 741 | 742 | const el = mount(); 743 | 744 | // Using `sinon-chai`, we can make readable assertions about whether 745 | // a spy function has been called. We don't expect our effect to 746 | // be invoked when the component mounts, so let's make that assertion 747 | // here. 748 | expect(effects.setGivenName).not.to.have.been.called; 749 | // Next, we can simulate a input-box value change. 750 | el.find("input.given").simulate("change", { 751 | target: { value: "Eric" } 752 | }); 753 | // And finally, we can assert that the effect - or, rather, the Sinon 754 | // spy that is standing in for the effect - was invoked with the expected 755 | // value. 756 | expect(effects.setGivenName).to.have.been.calledWith("Eric"); 757 | }); 758 | }); 759 | ``` 760 | 761 | That takes care of your SFCs. This should really be no different than how you might have been testing your presentational components in the past, except that with `freactal`, this is the _only_ sort of testing you need to do for your React components. 762 | 763 | 764 | ### State and effects 765 | 766 | Next up is state. As you read through the example below, take note that we can make assertions about the initial state and any expected transformations to that state without involving a React component or rendering to the DOM. 767 | 768 | ```javascript 769 | /*** state.spec.js ***/ 770 | 771 | import { wrapComponentWithState } from "./state"; 772 | 773 | describe("state container", () => { 774 | it("supports fullName", () => { 775 | // Normally, you'd pass a component as the first argument to your 776 | // state template. However, if you pass no argument to the state 777 | // template, you'll get back a test instance that you can extract 778 | // `state` and `effects` from. Just don't try to render the thing! 779 | const { effects, getState } = wrapComponentWithState(); 780 | 781 | expect(getState().fullName).to.equal("Walter Harriman"); 782 | 783 | // Since effects return a Promise, we're going to make it easy 784 | // on ourselves and wrap all of our assertions from this point on 785 | // inside a Promise. 786 | return Promise.resolve() 787 | // When a Promise is provided as the return value to a Promise's 788 | // `.then` callback, the outer Promise awaits the inner before 789 | // any subsequent callbacks are fired. 790 | .then(() => effects.setGivenName("Alfred")) 791 | // Now that `givenName` has been set to "Alfred", we can make an 792 | // assertion... 793 | .then(() => expect(getState().fullName).to.equal("Alfred Harriman")) 794 | // Then we can do the same for the family name... 795 | .then(() => effects.setFamilyName("Hitchcock")) 796 | // And make one final assertion. 797 | .then(() => expect(getState().fullName).to.equal("Alfred Hitchcock")); 798 | }); 799 | 800 | // You could write similar assertions here 801 | it("supports a greeting"); 802 | }); 803 | ``` 804 | 805 | That's it for testing! 806 | 807 | 808 |

809 | 810 | 811 | ### Conclusion 812 | 813 | We hope that you found this guide to be helpful! 814 | 815 | If you find that a piece is missing that would've helped you understand `freactal`, please feel free to [open an issue](https://github.com/FormidableLabs/freactal/issues/new). For help working through a problem, [reach out on Twitter](http://twitter.com/divmain), open an issue, or ping us on [Gitter](https://gitter.im/FormidableLabs/freactal). 816 | 817 | You can also read through the API docs below! 818 | 819 | 820 |

821 | 822 | 823 | ## API Documentation 824 | 825 | ### `provideState` 826 | 827 | This is used to define a state container, which in turn can wrap one of your application components. 828 | 829 | ```javascript 830 | const StatefulComponent = provideState({/* options */})(StatelessComponent); 831 | ``` 832 | 833 | The `options` argument is an object with one or more of the following keys: `initialState`, `effects`, `initialize`, and `computed`. 834 | 835 | 836 | #### `initialState` 837 | 838 | A function defining the state of your state container when it is first initialized. 839 | 840 | This function is invoked both on the server during a server-side render and on the client. However, you might employ environment detection in order to yield divergent results. 841 | 842 | ```javascript 843 | provideState({ 844 | initialState: () => ({ 845 | a: "value will", 846 | b: "set here" 847 | }) 848 | }) 849 | ``` 850 | Component props can be passed to `initialState` like so : 851 | 852 | ```javascript 853 | provideState({ 854 | initialState: ({ value }) => ({ 855 | a: value, 856 | b: "set here" 857 | }) 858 | }) 859 | ``` 860 | 861 | 862 | #### `effects` 863 | 864 | Effects are the mechanism by which state is updated. 865 | 866 | The `effects` value should be an object, where the keys are function names (that you will later) and the values are functions. 867 | 868 | Each effect will be provided one or more arguments: an `effects` reference (see note below), and any arguments that are passed to the function when they're invoked in application code. 869 | 870 | The return value is either 1) a function that takes in old state and returns new state or, 2) a Promise that resolves to #1. 871 | 872 | This may seem opaque, so please refer to the [guide](#effect-arguments) for information on how to use them effectively. 873 | 874 | ```javascript 875 | provideState({ 876 | effects: { 877 | doThing: (effects, argA) => 878 | Promise.resolve(state => Object.assign({}, state, { val: argA })) 879 | } 880 | }); 881 | ``` 882 | 883 | **NOTE 1:** The effects are called synchronously so you that you can use directly any passed events: 884 | 885 | ```javascript 886 | provideState({ 887 | effects: { 888 | onInputChange: (effects, event) => { 889 | const { value } = event.target 890 | return state => 891 | Object.assign({}, state, { inputValue: value }) 892 | } 893 | } 894 | }); 895 | 896 | const MyComponent = injectState(({ effects, state }) => ( 897 | 898 | )) 899 | ``` 900 | 901 | **NOTE 2:** The `effects` object that is passed to each effect is _not_ the same as the outer effects object that you define here. Instead, that object is a composition of the hierarchy of stateful effects. 902 | 903 | ##### `initialize` 904 | 905 | Each state container can define a special effect called `initialize`. This effect has props passed in as a second argument and will be implicitly invoked in two circumstances: 906 | 907 | 1. During SSR, each state container with an `initialize` effect will invoke it, and the rendering process will await the resolution of that effect before continuing with rendering. 908 | 2. When running in the browser, each state container with an `initialize` effect will invoke it when the container is mounted into the DOM. 909 | 910 | Note: this effect will NOT be passed down to a component's children. 911 | 912 | 913 | #### `computed` 914 | 915 | The `computed` object allows you to define compound state values that depend on basic state values or other computed values. 916 | 917 | The value provided as the `computed` option should be an object where each key is the name by which the computed value will be referenced, and each value is a function taking in state and returning a computed value. 918 | 919 | ```javascript 920 | provideState({ 921 | initialState: () => ({ 922 | a: "value will", 923 | b: "set here" 924 | }), 925 | computed: { 926 | aPlusB: ({ a, b }) => `${a} + ${b}`, // "value will + set here" 927 | typeOfAPlusB: ({ aPlusB }) => typeof aPlusB // "string" 928 | } 929 | }) 930 | ``` 931 | 932 | Props can't be passed to `computed` 933 | 934 | #### `middleware` 935 | 936 | Middleware is defined per state container, not globally. Each middleware function will be invoked in the order provided whenever a state change has occurred. 937 | 938 | With middleware, you should be able to inject new state values, intercept effects before they begin, track when effects complete, and modify the way in which sub-components interact and respond to state containers further up the tree. 939 | 940 | To write middleware effectively, you'll probably want to take a look at the Freactal's internal `buildContext` method. Fortunately it is pretty straightforward. 941 | 942 | The following is an example that will log out whenever an effect is invoked, the arguments it was provided, and when the effect completed: 943 | 944 | ```javascript 945 | provideState({ 946 | middleware: [ 947 | freactalCxt => Object.assign({}, freactalCxt, { 948 | effects: Object.keys(freactalCxt.effects).reduce((memo, key) => { 949 | memo[key] = (...args) => { 950 | console.log("Effect started", key, args); 951 | return freactalCxt.effects[key](...args).then(result => { 952 | console.log("Effect completed", key); 953 | return result; 954 | }) 955 | }; 956 | return memo; 957 | }, {}) 958 | }) 959 | ] 960 | }) 961 | ``` 962 | 963 | 964 | ### `injectState` 965 | 966 | While `provideState` supplies the means by which you declare your state and its possible transitions, `injectState` is the means by which you access `state` and `effects` from your UI code. 967 | 968 | By default, `injectState` will detect the keys that you access in your component, and will only force a re-render if those keys change in the upstream state container. 969 | 970 | ```javascript 971 | const StatelessComponent = ({ state: { myValue } }) => 972 |
{ myValue }
973 | const WithState = injectState(StatelessComponent); 974 | ``` 975 | 976 | In the above example, `StatelessComponent` would only be re-rendered a second time if `myValue` changed in the upstream state container. 977 | 978 | However, it is possible to explicitly define which keys you want to "listen" to. When using this form, the keys that you specify are injected into the wrapped component as props. 979 | 980 | ```javascript 981 | const StatelessComponent = ({ myValue }) => 982 |
{ myValue }
983 | const StatefulComponent = injectState(StatelessComponent, ["myValue", "otherValueToo"]); 984 | ``` 985 | 986 | In this example, `StatelessComponent` would re-render when `myValue` changed, but it would also re-render when `otherValueToo` changed, even though that value is not used in the component. 987 | 988 | 989 | ### `hydrate` and `initialize` 990 | 991 | These functions are used to deeply initialize state in the SSR context and then re-hydrate that state on the client. For more information about how to use these functions, see the below documentation on [Server-side Rendering](#server-side-rendering). 992 | 993 | 994 |

995 | 996 | 997 | ## Helper functions 998 | 999 | You may find the following functions handy, as a shorthand for common tasks. 1000 | 1001 | 1002 | ### `update` 1003 | 1004 | This handy helper provides better ergonomics when defining an effect that updates state. 1005 | 1006 | It can be consumed like so: 1007 | 1008 | ```javascript 1009 | import { provideState, update } from "freactal"; 1010 | const wrapComponentWithState = provideState({ 1011 | // ... 1012 | effects: { 1013 | myEffect: update({ setThisKey: "to this value..." }) 1014 | } 1015 | }); 1016 | ``` 1017 | 1018 | Which is equivalent to the following: 1019 | 1020 | ```javascript 1021 | import { provideState } from "freactal"; 1022 | const wrapComponentWithState = provideState({ 1023 | // ... 1024 | effects: { 1025 | myEffect: () => state => Object.assign({}, state, { setThisKey: "to this value..." }) 1026 | } 1027 | }); 1028 | ``` 1029 | 1030 | When your update _is_ dependant on the previous state you can pass a function, like so: 1031 | 1032 | ```javascript 1033 | import { provideState, update } from "freactal"; 1034 | const wrapComponentWithState = provideState({ 1035 | // ... 1036 | effects: { 1037 | myEffect: update(state => ({ counter: state.counter + 1 })) 1038 | } 1039 | }); 1040 | ``` 1041 | 1042 | Which is equivalent to the following: 1043 | 1044 | ```javascript 1045 | import { provideState } from "freactal"; 1046 | const wrapComponentWithState = provideState({ 1047 | // ... 1048 | effects: { 1049 | myEffect: () => state => Object.assign({}, state, { counter: state.counter + 1 }) 1050 | } 1051 | }); 1052 | ``` 1053 | 1054 | Any arguments that are passed to the invocation of your effect will also be passed to the function you provide to `update`. 1055 | 1056 | I.e. 1057 | 1058 | ```javascript 1059 | effects: { 1060 | updateCounterBy: (effects, addVal) => state => Object.assign({}, state, { counter: state.counter + addVal }) 1061 | } 1062 | ``` 1063 | 1064 | is equivalent to: 1065 | 1066 | ```javascript 1067 | effects: { 1068 | myEffect: update((state, addVal) => ({ counter: state.counter + addVal })) 1069 | } 1070 | ``` 1071 | 1072 | 1073 | ### `mergeIntoState` 1074 | 1075 | `update` is intended for synchronous updates only. But writing out a state-update function for asynchronous effects can get tedious. That's where `mergeIntoState` comes in. 1076 | 1077 | ```javascript 1078 | mergeIntoState(newData) 1079 | ``` 1080 | 1081 | ... is exactly equivalent to... 1082 | 1083 | ```javascript 1084 | state => Object.assign({}, state, newData) 1085 | ``` 1086 | 1087 | Here's what it might look like in practice: 1088 | 1089 | ```javascript 1090 | export const getData = (effects, dataId) => fetch(`http://some.url/${dataId}`) 1091 | .then(response => response.json()) 1092 | .then(body => mergeIntoState({ data: body.data })); 1093 | ``` 1094 | 1095 | 1096 |

1097 | 1098 | 1099 | ## Server-side Rendering 1100 | 1101 | Historically, server-side rendering of stateful React applications has involved many moving pieces. `freactal` aims to simplify this area without sacrificing the power of its fractal architecture. 1102 | 1103 | There are two parts to achieving SSR with `freactal`: state initialization on the server, and state hydration on the client. 1104 | 1105 | Keep in mind that, if you have a state container whose state needs to be initialized in a particular way, you should take a look at the [`initialize`](#initialize) effect. 1106 | 1107 | `freactal` supports both React's built-in `renderToString` method, as well as the newer [Rapscallion](https://github.com/FormidableLabs/rapscallion). 1108 | 1109 | ### with `React#renderToString` 1110 | 1111 | On the server, you'll need to recursively initialize your state tree. This is accomplished with the `initialize` function, provided by `freactal/server`. 1112 | 1113 | ```javascript 1114 | /* First, import renderToString and the initialize function. */ 1115 | import { renderToString } from "react-dom/server"; 1116 | import { initialize } from "freactal/server"; 1117 | 1118 | /* 1119 | Within the context of your Node.js server route, pass the root component to 1120 | the initialize function. 1121 | */ 1122 | initialize() 1123 | /* This invocation will return a Promise that resolves to VDOM and state */ 1124 | .then(({ vdom, state }) => { 1125 | /* Pass the VDOM to renderToString to get HTML out. */ 1126 | const appHTML = renderToString(vdom); 1127 | /* 1128 | Pass your application HTML and the application state (an object) to a 1129 | function that inserts application HTML into and tags, 1130 | serializes state, and inserts that state into an accessible part of 1131 | the DOM. 1132 | */ 1133 | const html = boilerplate(appHTML, state); 1134 | /* Finally, send the full-page HTML to the client */ 1135 | return res.send(html).end(); 1136 | }) 1137 | ``` 1138 | 1139 | You can find a full `freactal` example, including a server and SSR [here](https://github.com/FormidableLabs/freactal/tree/master/example). 1140 | 1141 | 1142 | ### with Rapscallion 1143 | 1144 | The above method involves a partial render of your application (`initialize`), ultimately relying upon `React.renderToString` to transform the VDOM into an HTML string. This is because `renderToString` is synchronous, and `freactal` is asynchronous by design. 1145 | 1146 | Because Rapscallion is also asynchronous by design, there is even less ceremony involved. 1147 | 1148 | ```javascript 1149 | /* First, import Rapscallion's render and the captureState function. */ 1150 | import { render } from "rapscallion"; 1151 | import { captureState } from "freactal/server"; 1152 | 1153 | /* 1154 | Within the context of your Node.js server route, invoke `captureState` with your root component. 1155 | */ 1156 | const { Captured, state } = captureState(); 1157 | 1158 | /* Pass the component to Rapscallion's renderer */ 1159 | render() 1160 | .toPromise() 1161 | .then(appHTML => { 1162 | /* 1163 | At this point, the `state` object will be fully populated with your 1164 | state tree's data. 1165 | 1166 | Pass your application HTML and state to a function that inserts 1167 | application HTML into and tags, serializes state, and 1168 | inserts that state into an accessible part of the DOM. 1169 | */ 1170 | const html = boilerplate(appHTML, state); 1171 | /* Finally, send the full-page HTML to the client */ 1172 | return res.send(html).end(); 1173 | }); 1174 | ``` 1175 | 1176 | 1177 | ### Hydrate state on the client 1178 | 1179 | Using one of the above methods, you can capture your application state while server-side rendering and insert it into the resulting HTML. The final piece of the SSR puzzle is re-hydrating your state containers inside the browser. 1180 | 1181 | This is accomplished with `hydrate` in the context of your `initialState` function. 1182 | 1183 | Assuming you've serialized the SSR state and exposed it as `window.__state__`, your root state container should look something like this: 1184 | 1185 | ```javascript 1186 | import { provideState, hydrate } from "freactal"; 1187 | 1188 | const IS_BROWSER = typeof window === "object"; 1189 | const stateTemplate = provideState({ 1190 | initialState: IS_BROWSER ? 1191 | hydrate(window.__state__) : 1192 | () => { /* your typical state values */ }, 1193 | effects: { /* ... */ }, 1194 | computed: { /* ... */ } 1195 | }); 1196 | ``` 1197 | 1198 | In SSR, your `typical state values` will be provided as your initial state. In the browser, the initial state will be read from `window.__state__`. 1199 | 1200 | Assuming you've done this with your root state container, you can similarly re-hydrate nested state containers like so: 1201 | 1202 | ```javascript 1203 | import { provideState, hydrate } from "freactal"; 1204 | 1205 | const IS_BROWSER = typeof window === "object"; 1206 | const stateTemplate = provideState({ 1207 | initialState: IS_BROWSER ? 1208 | hydrate() : 1209 | () => { /* your typical state values */ }, 1210 | effects: { /* ... */ }, 1211 | computed: { /* ... */ } 1212 | }); 1213 | ``` 1214 | 1215 | Note that there is no need to pass `window.__state__` to the `hydrate` function for nested state containers. 1216 | 1217 | 1218 |

1219 | 1220 | 1221 | ## Maintenance Status 1222 | 1223 | **Archived:** This project is no longer maintained by Formidable. We are no longer responding to issues or pull requests unless they relate to security concerns. We encourage interested developers to fork this project and make it their own! 1224 | --------------------------------------------------------------------------------