From a0025d881d9804de9811166f9573c7725b832279 Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Wed, 25 Mar 2020 22:35:16 +0100 Subject: [PATCH 1/6] feat(auto-effect): add autoEffect and clearEffect --- __tests__/autoEffect.no-hook.test.jsx | 48 ++++++++++++++ __tests__/autoEffect.test.jsx | 95 +++++++++++++++++++++++++++ __tests__/store.no-hook.test.jsx | 2 +- src/autoEffect.js | 30 +++++++++ src/index.js | 3 +- 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 __tests__/autoEffect.no-hook.test.jsx create mode 100644 __tests__/autoEffect.test.jsx create mode 100644 src/autoEffect.js diff --git a/__tests__/autoEffect.no-hook.test.jsx b/__tests__/autoEffect.no-hook.test.jsx new file mode 100644 index 0000000..2f1f9e6 --- /dev/null +++ b/__tests__/autoEffect.no-hook.test.jsx @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import { render, cleanup } from '@testing-library/react/pure'; +// eslint-disable-next-line import/no-unresolved +import { view, store, autoEffect } from 'react-easy-state'; + +describe('AutoEffect edge cases and errors', () => { + afterEach(cleanup); + + test(`Using autoEffect in a function component ${ + process.env.NOHOOK + ? 'with a version of react that has no hooks should' + : 'should not' + } throw an error`, () => { + const someEffect = () => {}; + + const MyComp = view(() => { + const person = store({ name: 'Bob' }); + autoEffect(() => someEffect(person.name)); + return
{person.name}
; + }); + + if (process.env.NOHOOK) { + expect(() => render()).toThrow( + 'You cannot use autoEffect inside a function component with a pre-hooks version of React. Please update your React version to at least v16.8.0 to use this feature.', + ); + } else { + expect(() => render()).not.toThrow(); + } + }); + + test('Using autoEffect inside a render of a class component should throw an error', () => { + const someEffect = () => {}; + const person = store({ name: 'Bob' }); + + const MyComp = view( + class extends Component { + render() { + autoEffect(() => someEffect(person.name)); + return
{person.name}
; + } + }, + ); + + expect(() => render()).toThrow( + 'You cannot use autoEffect inside a render of a class component. Please use it in the constructor or lifecycle methods instead.', + ); + }); +}); diff --git a/__tests__/autoEffect.test.jsx b/__tests__/autoEffect.test.jsx new file mode 100644 index 0000000..7984d19 --- /dev/null +++ b/__tests__/autoEffect.test.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, cleanup, act } from '@testing-library/react/pure'; +import { + view, + store, + autoEffect, + clearEffect, + // eslint-disable-next-line import/no-unresolved +} from 'react-easy-state'; + +describe.only('autoEffect', () => { + afterEach(cleanup); + + test('should auto run global effects', () => { + let documentTitle = ''; + const app = store({ name: 'Online Store' }); + + const effect = autoEffect(() => { + documentTitle = app.name; + }); + expect(documentTitle).toBe('Online Store'); + + act(() => { + app.name = 'Learning Platform'; + }); + expect(documentTitle).toBe('Learning Platform'); + + clearEffect(effect); + act(() => { + app.name = 'Social Platform'; + }); + expect(documentTitle).toBe('Learning Platform'); + }); + + test('should auto run local effects in function components', () => { + let documentTitle = ''; + + const app = store({ name: 'Online Store' }); + + const MyComp = view(() => { + autoEffect(() => { + documentTitle = app.name; + }); + return
{app.name}
; + }); + + const { container, unmount } = render(); + expect(container).toHaveTextContent('Online Store'); + expect(documentTitle).toBe('Online Store'); + + act(() => { + app.name = 'Learning Platform'; + }); + expect(container).toHaveTextContent('Learning Platform'); + expect(documentTitle).toBe('Learning Platform'); + + unmount(); + act(() => { + app.name = 'Social Platform'; + }); + expect(documentTitle).toBe('Learning Platform'); + }); + + test('should be recreated on dependency changes in function components', () => { + let documentTitle = ''; + + const app = store({ name: 'Store' }); + + const MyComp = view(({ name }) => { + autoEffect(() => { + documentTitle = `${name} ${app.name}`; + }, [name]); + return ( +
+ {name} + {app.name} +
+ ); + }); + + const { container, rerender } = render(); + expect(container).toHaveTextContent('Online Store'); + expect(documentTitle).toBe('Online Store'); + + rerender(); + expect(container).toHaveTextContent('Awesome Store'); + expect(documentTitle).toBe('Awesome Store'); + + act(() => { + app.name = 'Page'; + }); + expect(container).toHaveTextContent('Awesome Page'); + expect(documentTitle).toBe('Awesome Page'); + }); +}); diff --git a/__tests__/store.no-hook.test.jsx b/__tests__/store.no-hook.test.jsx index caa5911..550f105 100644 --- a/__tests__/store.no-hook.test.jsx +++ b/__tests__/store.no-hook.test.jsx @@ -3,7 +3,7 @@ import { render, cleanup } from '@testing-library/react/pure'; // eslint-disable-next-line import/no-unresolved import { view, store } from 'react-easy-state'; -describe('Using an old react version', () => { +describe('Store edge cases and errors', () => { afterEach(cleanup); test(`Using local state in a function component ${ diff --git a/src/autoEffect.js b/src/autoEffect.js new file mode 100644 index 0000000..27d6a84 --- /dev/null +++ b/src/autoEffect.js @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import { observe, unobserve } from '@nx-js/observer-util'; + +import { + isInsideFunctionComponent, + isInsideClassComponentRender, + isInsideFunctionComponentWithoutHooks, +} from './view'; + +export function autoEffect(fn, deps = []) { + if (isInsideFunctionComponent) { + return useEffect(() => { + const observer = observe(fn); + return () => unobserve(observer); + }, deps); + } + if (isInsideFunctionComponentWithoutHooks) { + throw new Error( + 'You cannot use autoEffect inside a function component with a pre-hooks version of React. Please update your React version to at least v16.8.0 to use this feature.', + ); + } + if (isInsideClassComponentRender) { + throw new Error( + 'You cannot use autoEffect inside a render of a class component. Please use it in the constructor or lifecycle methods instead.', + ); + } + return observe(fn); +} + +export { unobserve as clearEffect }; diff --git a/src/index.js b/src/index.js index 6574ba1..f462aff 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,4 @@ -export { observe, unobserve } from '@nx-js/observer-util'; - export { view } from './view'; export { store } from './store'; export { batch } from './scheduler'; +export { autoEffect, clearEffect } from './autoEffect'; From a2708877ee54a8fb915053de57322b9b94d18a4b Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Thu, 26 Mar 2020 00:06:28 +0100 Subject: [PATCH 2/6] docs(auto-effect): document auto effects --- README.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96c1c07..027a431 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ export default view(() => { **Local stores in functions rely on React hooks. They require React and React DOM v16.8+ or React Native v0.59+ to work.**
-You can use any React hook - including useState - in function components, Easy State won't interfere with them. +You can use React hooks - including useState - in function components, Easy State won't interfere with them. Please use [autoEffect](#local-auto-effects-in-function-components) instead of the `useEffect` hook for the best experience though.

```jsx @@ -623,6 +623,105 @@ Instead of returning an object, you should directly mutate the received stores.
+### Adding side effects + +Use `autoEffect` to react with automatic side effect to your store changes. Auto effects should contain end-of-chain logic - like changing the document title or saving data to LocalStorage. `view` is a special auto effect that does rendering. + +
+Never use auto effects to derive data from other data. Use dynamic getters instead. +

+ +```jsx +import { store, autoEffect } from 'react-easy-state'; + +// DON'T DO THIS +const store1 = store({ name: 'Store 1' }) +const store2 = store({ name: 'Store 2' }) +autoEffect(() => store2.name = store1.name) + +// DO THIS INSTEAD +const store1 = store({ name: 'Store 1' }) +const store2 = store({ get name () { return store1.name } }) +``` + +
+

+ +#### Global auto effects + +Global auto effects can be created with `autoEffect` and cleared up with `clearEffect`. + +```jsx +import { store, autoEffect, clearEffect } from 'react-easy-state'; + +const app = store({ name: 'My App' }) +const effect = autoEffect(() => document.title = app.name) + +// this also updates the document title +app.name = 'My Awesome App' + +clearEffect(effect) +// this won't update the document title, the effect is cleared +app.name = 'My App' +``` + +#### Local auto effects in function components + +Use local auto effects in function components instead of the `useEffect` hook when reactive stores are used inside them. These local effects are automatically cleared when the component unmounts. + +```jsx +import React from 'react' +import { store, view, autoEffect } from 'react-easy-state'; + +export default view(() => { + const app = store({ name: 'My App' }) + // no need to clear the effect + autoEffect(() => document.title = app.name) +}) +``` + +
+Explicitly pass none reactive dependencies - like vanillas props and state - to local auto effects in function components. +

+ +Because of the design of React hooks you have to explicitly pass all none reactive data to a hook-like dependency array. This makes sure that the effect also runs when the none reactive data changes. + +```jsx +import React from 'react' +import { store, view, autoEffect } from 'react-easy-state'; + +export default view(({ greeting }) => { + const app = store({ name: 'My App' }) + // pass `greeting` in the dependency array because it is not coming from a store + autoEffect(() => document.title = `${greeting} ${app.name}`, [greeting]) +}) +``` + +
+

+ +#### Local auto effects in class components + +Local effects in class components must be cleared when the component unmounts. + +```jsx +import React, { Component } from 'react' +import { store, view, autoEffect } from 'react-easy-state'; + +class App extends Component { + app = store({ name: 'My App' }) + + componentDidMount () { + this.effect = autoEffect(() => document.title = this.app.name) + } + + componentWillUnmount () { + // local effects in class components must be cleared on unmount + clearEffect(this.effect) + } +} +``` + --- ## Examples with live demos From d4e6d9f2087bb355e1cba47c4850c94d41fdbb72 Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Thu, 26 Mar 2020 00:12:47 +0100 Subject: [PATCH 3/6] fix(auto-effect-test): fix a linter related testing issue --- .eslintrc.json | 3 ++- __tests__/autoEffect.test.jsx | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index cc9e7b1..4016ec3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,7 +15,8 @@ "react/destructuring-assignment": "off", "react/state-in-constructor": "off", "react/jsx-props-no-spreading": "off", - "react/prop-types": "off" + "react/prop-types": "off", + "react/jsx-one-expression-per-line": "off" }, "globals": { "window": true, diff --git a/__tests__/autoEffect.test.jsx b/__tests__/autoEffect.test.jsx index 7984d19..d73b396 100644 --- a/__tests__/autoEffect.test.jsx +++ b/__tests__/autoEffect.test.jsx @@ -72,8 +72,7 @@ describe.only('autoEffect', () => { }, [name]); return (
- {name} - {app.name} + {name} {app.name}
); }); From 3642c12f9348f2aaebc614f78b3dddb22e89da20 Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Thu, 26 Mar 2020 00:17:31 +0100 Subject: [PATCH 4/6] fix(auto-effects-test): fix a store placement issue in tests --- __tests__/autoEffect.no-hook.test.jsx | 2 +- __tests__/autoEffect.test.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/autoEffect.no-hook.test.jsx b/__tests__/autoEffect.no-hook.test.jsx index 2f1f9e6..11bd99e 100644 --- a/__tests__/autoEffect.no-hook.test.jsx +++ b/__tests__/autoEffect.no-hook.test.jsx @@ -12,9 +12,9 @@ describe('AutoEffect edge cases and errors', () => { : 'should not' } throw an error`, () => { const someEffect = () => {}; + const person = store({ name: 'Bob' }); const MyComp = view(() => { - const person = store({ name: 'Bob' }); autoEffect(() => someEffect(person.name)); return
{person.name}
; }); diff --git a/__tests__/autoEffect.test.jsx b/__tests__/autoEffect.test.jsx index d73b396..bc4122a 100644 --- a/__tests__/autoEffect.test.jsx +++ b/__tests__/autoEffect.test.jsx @@ -8,7 +8,7 @@ import { // eslint-disable-next-line import/no-unresolved } from 'react-easy-state'; -describe.only('autoEffect', () => { +describe('autoEffect', () => { afterEach(cleanup); test('should auto run global effects', () => { From 9d2e7c53f7837abdf9a5c557b576a46fbd1d70ff Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Thu, 26 Mar 2020 00:35:26 +0100 Subject: [PATCH 5/6] docs(local-stores): fix a badly formatted link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 027a431..4297c1c 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ export default view(() => { **Local stores in functions rely on React hooks. They require React and React DOM v16.8+ or React Native v0.59+ to work.**
-You can use React hooks - including useState - in function components, Easy State won't interfere with them. Please use [autoEffect](#local-auto-effects-in-function-components) instead of the `useEffect` hook for the best experience though. +You can use React hooks - including useState - in function components, Easy State won't interfere with them. Consider using autoEffect instead of the `useEffect` hook for the best experience though.

```jsx From 8981fef8c96f8b80d871dce3b7c323449b09c3c1 Mon Sep 17 00:00:00 2001 From: Miklos Bertalan Date: Thu, 26 Mar 2020 00:44:06 +0100 Subject: [PATCH 6/6] docs(toc): update the table of contents --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4297c1c..db4dda8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Simple React state management. Made with :heart: and ES6 Proxies. + [Creating global stores](#creating-global-stores) + [Creating reactive views](#creating-reactive-views) + [Creating local stores](#creating-local-stores) + + [Adding side effects](#adding-side-effects) * [Examples with live demos](#examples-with-live-demos) * [Articles](#articles) * [Performance](#performance)