├── example ├── .npmignore ├── index.scss ├── tsconfig.json ├── package.json ├── index.html └── index.tsx ├── .gitignore ├── netlify.toml ├── README.md ├── tsconfig.json ├── package.json ├── LICENSE ├── src └── index.ts └── test └── coiled.test.ts /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "." 3 | publish = "./example/dist" 4 | command = "yarn && cd ./example && yarn && yarn build" 5 | -------------------------------------------------------------------------------- /example/index.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: sans-serif; 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | .parent { 9 | position: relative; 10 | width: 100vw; 11 | height: 100vh; 12 | overflow: hidden; 13 | 14 | .explainer { 15 | padding: 20px; 16 | max-width: 70%; 17 | } 18 | 19 | .item { 20 | position: absolute; 21 | user-select: none; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coiled (100 line Recoil clone) 2 | 3 | A [Recoil](https://recoiljs.org/) clone written in under 100 lines (excluding comments, examples and tests). 4 | 5 | It was written in the article ["Rewriting Facebook's \"Recoil\" React library from scratch in 100 lines"](https://bennetthardwick.com/blog/recoil-js-clone-from-scratch-in-100-lines/). 6 | 7 | Here's a [live example](https://100-line-recoil-clone.netlify.app/) for you to play with. 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "jsx": "react", 17 | "esModuleInterop": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noImplicitAny": false, 10 | "noUnusedLocals": false, 11 | "noUnusedParameters": false, 12 | "removeComments": true, 13 | "strictNullChecks": true, 14 | "preserveConstEnums": true, 15 | "sourceMap": true, 16 | "lib": ["es2015", "es2016", "dom"], 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "sass": "^1.26.10", 23 | "typescript": "^3.4.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "prepare": "tsdx build" 18 | }, 19 | "peerDependencies": { 20 | "react": ">=16" 21 | }, 22 | "prettier": { 23 | "printWidth": 80, 24 | "semi": true, 25 | "singleQuote": true, 26 | "trailingComma": "es5" 27 | }, 28 | "name": "recoil-clone", 29 | "author": "Bennett Hardwick", 30 | "module": "dist/recoil-clone.esm.js", 31 | "devDependencies": { 32 | "@testing-library/react-hooks": "^3.4.1", 33 | "@types/react": "^16.9.49", 34 | "husky": "^4.3.0", 35 | "react": "^16.13.1", 36 | "react-dom": "^16.13.1", 37 | "react-test-renderer": "^16.13.1", 38 | "tsdx": "^0.13.3", 39 | "tslib": "^2.0.1", 40 | "typescript": "^4.0.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bennett Hardwick 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. -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { atom, Selector, selector, useCoiledValue } from '../.'; 4 | import './index.scss'; 5 | 6 | function getRandomColor(): string { 7 | const letters = '0123456789ABCDEF'; 8 | var color = '#'; 9 | for (var i = 0; i < 6; i++) { 10 | color += letters[Math.floor(Math.random() * 16)]; 11 | } 12 | return color; 13 | } 14 | 15 | interface ItemCoords { 16 | x: number; 17 | y: number; 18 | } 19 | 20 | const state = atom<{ [key: string]: ItemCoords }>({ 21 | key: 'items', 22 | default: {}, 23 | }); 24 | 25 | const selected = atom({ 26 | key: 'selectedItem', 27 | default: undefined, 28 | }); 29 | 30 | const selectedItemSelector = selector({ 31 | key: 'selectedItemCoords', 32 | get: ({ get }) => { 33 | const current = get(selected); 34 | const items = get(state); 35 | 36 | if (!current) { 37 | return undefined; 38 | } else { 39 | return items[current]; 40 | } 41 | }, 42 | }); 43 | 44 | const ITEM_CACHE = new Map>(); 45 | 46 | function getOrCreateSelector(item: string): Selector { 47 | if (ITEM_CACHE.has(item)) { 48 | return ITEM_CACHE.get(item)!; 49 | } else { 50 | const s = selector({ 51 | key: `item-${item}`, 52 | get: ({ get }) => get(state)[item]!, 53 | }); 54 | 55 | ITEM_CACHE.set(item, s); 56 | 57 | return s; 58 | } 59 | } 60 | 61 | function updateItem(item: string, x: number, y: number) { 62 | state.setState({ 63 | ...state.snapshot(), 64 | [item]: { x, y }, 65 | }); 66 | } 67 | 68 | let currentIndex = 0; 69 | 70 | function getNextId(): string { 71 | return `${currentIndex++}`; 72 | } 73 | 74 | const SIZE = 200; 75 | 76 | updateItem( 77 | getNextId(), 78 | window.innerWidth / 2 - SIZE / 2, 79 | window.innerHeight / 2 - SIZE / 2 80 | ); 81 | 82 | const Item: React.FC<{ item: string }> = React.memo(({ item }) => { 83 | const selector = getOrCreateSelector(item); 84 | const value = useCoiledValue(selector); 85 | 86 | const [moving, setMoving] = React.useState(false); 87 | 88 | return ( 89 |
{ 99 | setMoving(true); 100 | selected.setState(item); 101 | }} 102 | onMouseUp={() => { 103 | setMoving(false); 104 | selected.setState(undefined); 105 | }} 106 | onMouseOut={() => setMoving(false)} 107 | onMouseMove={e => { 108 | if (moving) { 109 | state.setState({ 110 | ...state.snapshot(), 111 | [item]: { 112 | x: e.clientX - SIZE / 2, 113 | y: e.clientY - SIZE / 2, 114 | }, 115 | }); 116 | } 117 | }} 118 | /> 119 | ); 120 | }); 121 | 122 | const Selected: React.FC = React.memo(() => { 123 | const selected = useCoiledValue(selectedItemSelector); 124 | 125 | if (!selected) { 126 | return
No item selected.
; 127 | } 128 | 129 | return ( 130 |
131 | Current position: x ({selected.x}) y ({selected.y}) 132 |
133 | ); 134 | }); 135 | 136 | const App = () => { 137 | const items = useCoiledValue(state); 138 | 139 | return ( 140 |
141 |
142 | This is a test for a{' '} 143 | 144 | Recoil clone written in under 100 lines 145 | 146 | . Each component will change colour when it's rendered, this shows that 147 | only the component that is being interacted with is updating when the 148 | state changes. 149 |
150 |
151 | 162 |
163 |
164 | 165 |
166 | {Object.keys(items).map(item => ( 167 | 168 | ))} 169 |
170 | ); 171 | }; 172 | 173 | ReactDOM.render(, document.getElementById('root')); 174 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // A few dependencies to allow working with React. Other than these 2 | // we're totally dependency free! 3 | import { useState, useEffect, useCallback } from 'react'; 4 | 5 | // An interface with the disconnect method. This could just be a function 6 | // but I think having it as an object is more readable. 7 | interface Disconnect { 8 | disconnect: () => void; 9 | } 10 | 11 | // `Stateful` is the base class that manages states and subscriptions. 12 | // Both Atom and Selector are derived from it. 13 | export class Stateful { 14 | // This is a set of unique callbacks. The callbacks are listeners 15 | // that have subscribed 16 | private listeners = new Set<(value: T) => void>(); 17 | 18 | // The value property is protected because it needs to be manually 19 | // assigned in the constructor (because of inheritance quirks) 20 | constructor(protected value: T) {} 21 | 22 | // Simple method for returning the state. This could return a deep 23 | // copy if you wanted to be extra cautious. 24 | snapshot(): T { 25 | return this.value; 26 | } 27 | 28 | // The emit method is what updates all the listeners with the new state 29 | private emit() { 30 | for (const listener of Array.from(this.listeners)) { 31 | listener(this.snapshot()); 32 | } 33 | } 34 | 35 | // The update method is the canonical way to set state. It uses object 36 | // equality to prevent unnecessary renders. A deep comparison could be 37 | // performed for complex objects that are often re-created but are the 38 | // same. 39 | protected update(value: T) { 40 | if (this.value !== value) { 41 | this.value = value; 42 | // After updating the value, let all the listeners know there's a 43 | // new state. 44 | this.emit(); 45 | } 46 | } 47 | 48 | // The subscribe method lets consumers listen for state updates. Calling 49 | // the `disconnect` method will stop the callback from being called in 50 | // the future. 51 | subscribe(callback: (value: T) => void): Disconnect { 52 | this.listeners.add(callback); 53 | return { 54 | disconnect: () => { 55 | this.listeners.delete(callback); 56 | }, 57 | }; 58 | } 59 | } 60 | 61 | // The atom is a thin wrapper around the `Stateful` base class. It has a 62 | // single method for updating the state. 63 | // 64 | // Note: `useState` allows you to pass a reducer function, you could add support 65 | // for this if you wanted. 66 | export class Atom extends Stateful { 67 | public setState(value: T) { 68 | super.update(value); 69 | } 70 | } 71 | 72 | // The Recoil selector function is a bit gnarley. Essentially the "get" function 73 | // is the way that selectors can subscribe to other selectors and atoms. 74 | type SelectorGenerator = (context: { get: (dep: Stateful) => V }) => T; 75 | 76 | // The selector class. It extends `Stateful` so that it can be used as a value like 77 | // atoms. 78 | export class Selector extends Stateful { 79 | // Keep track of all the registered dependencies. We want to make sure we only 80 | // re-render once when they change. 81 | private registeredDeps = new Set>(); 82 | 83 | // When the get function is called, it allows consumers to subscribe to state 84 | // changes. This method subscribes to the dependency if it hasn't been already, 85 | // then returns it's value. 86 | private addDep(dep: Stateful): V { 87 | if (!this.registeredDeps.has(dep)) { 88 | dep.subscribe(() => this.updateSelector()); 89 | this.registeredDeps.add(dep); 90 | } 91 | 92 | return dep.snapshot(); 93 | } 94 | 95 | // A helper method for running the internal generator method, updating dependencies, 96 | // returning the computed state and updating all listeners. 97 | private updateSelector() { 98 | this.update(this.generate({ get: dep => this.addDep(dep) })); 99 | } 100 | 101 | constructor(private readonly generate: SelectorGenerator) { 102 | // This needs to be undefined initially because of Typescript's inheritance rules 103 | // It's effectively "initialised memory" 104 | super(undefined as any); 105 | this.value = generate({ get: dep => this.addDep(dep) }); 106 | } 107 | } 108 | 109 | // A helper function for creating a new Atom 110 | // The `key` member is currently unused. I just kept it around to maintain a similar 111 | // API to Recoil. 112 | export function atom(value: { key: string; default: V }): Atom { 113 | return new Atom(value.default); 114 | } 115 | 116 | // A helper method for creating a new Selector 117 | // Likewise the `key` method is just for looking like Recoil. 118 | export function selector(value: { 119 | key: string; 120 | get: SelectorGenerator; 121 | }): Selector { 122 | return new Selector(value.get); 123 | } 124 | 125 | // This hook will re-render whenever the supplied `Stateful` value changes. 126 | // It can be used with `Selector`s or `Atom`s. 127 | export function useCoiledValue(value: Stateful): T { 128 | const [, updateState] = useState({}); 129 | 130 | useEffect(() => { 131 | const { disconnect } = value.subscribe(() => updateState({})); 132 | return () => disconnect(); 133 | }, [value]); 134 | 135 | return value.snapshot(); 136 | } 137 | 138 | // Similar to the above method, but it also lets you set state. 139 | export function useCoiledState(atom: Atom): [T, (value: T) => void] { 140 | const value = useCoiledValue(atom); 141 | return [value, useCallback(value => atom.setState(value), [atom])]; 142 | } 143 | -------------------------------------------------------------------------------- /test/coiled.test.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector, useCoiledState, useCoiledValue } from '../src'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | describe('Coiled', () => { 5 | describe('Stateful', () => { 6 | describe('useCoiledValue', () => { 7 | it('should remove all listeners when unmounted', () => { 8 | const state = atom({ key: 'switch', default: 'off' }); 9 | 10 | const result = renderHook(() => useCoiledValue(state)); 11 | 12 | expect(Array.from(state['listeners']).length).toBe(1); 13 | 14 | result.unmount(); 15 | 16 | expect(Array.from(state['listeners']).length).toBe(0); 17 | }); 18 | 19 | it('should not render if state is same', () => { 20 | let renders = 0; 21 | 22 | const state = atom<'on' | 'off'>({ key: 'switch', default: 'off' }); 23 | 24 | const result = renderHook(() => { 25 | renders += 1; 26 | return useCoiledValue(state); 27 | }); 28 | 29 | expect(renders).toBe(1); 30 | expect(result.result.current).toBe('off'); 31 | 32 | act(() => { 33 | state.setState('on'); 34 | }); 35 | 36 | expect(renders).toBe(2); 37 | expect(result.result.current).toBe('on'); 38 | 39 | act(() => { 40 | state.setState('on'); 41 | }); 42 | 43 | expect(renders).toBe(2); 44 | expect(result.result.current).toBe('on'); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('Atom', () => { 50 | it('should update whenever the state changes', () => { 51 | const state = atom<'on' | 'off'>({ key: 'switch', default: 'off' }); 52 | 53 | const result = renderHook(() => useCoiledState(state)); 54 | 55 | expect(result.result.current[0]).toBe('off'); 56 | 57 | act(() => { 58 | state.setState('on'); 59 | }); 60 | 61 | expect(result.result.current[0]).toBe('on'); 62 | }); 63 | 64 | it('should update the state', () => { 65 | const state = atom<'on' | 'off'>({ key: 'switch', default: 'off' }); 66 | 67 | const result = renderHook(() => useCoiledState(state)); 68 | 69 | expect(result.result.current[0]).toBe('off'); 70 | 71 | act(() => { 72 | result.result.current[1]('on'); 73 | }); 74 | 75 | expect(result.result.current[0]).toBe('on'); 76 | }); 77 | 78 | it('should remove all listeners when unmounted', () => { 79 | const state = atom({ key: 'switch', default: 'off' }); 80 | 81 | const result = renderHook(() => useCoiledState(state)); 82 | 83 | expect(Array.from(state['listeners']).length).toBe(1); 84 | 85 | result.unmount(); 86 | 87 | expect(Array.from(state['listeners']).length).toBe(0); 88 | }); 89 | 90 | it('should not render if state is same', () => { 91 | let renders = 0; 92 | 93 | const state = atom<'on' | 'off'>({ key: 'switch', default: 'off' }); 94 | 95 | const result = renderHook(() => { 96 | renders += 1; 97 | return useCoiledState(state); 98 | }); 99 | 100 | expect(renders).toBe(1); 101 | expect(result.result.current[0]).toBe('off'); 102 | 103 | act(() => { 104 | state.setState('on'); 105 | }); 106 | 107 | expect(renders).toBe(2); 108 | expect(result.result.current[0]).toBe('on'); 109 | 110 | act(() => { 111 | state.setState('on'); 112 | }); 113 | 114 | expect(renders).toBe(2); 115 | expect(result.result.current[0]).toBe('on'); 116 | }); 117 | }); 118 | 119 | describe('Selector', () => { 120 | it('should update when a dependency updates', () => { 121 | const person = atom({ key: 'person', default: 'John' }); 122 | const age = atom({ key: 'age', default: 20 }); 123 | 124 | const personsAge = selector({ 125 | key: 'persons-age', 126 | get: ({ get }) => `${get(person)} is ${get(age)} years old`, 127 | }); 128 | 129 | const result = renderHook(() => useCoiledValue(personsAge)); 130 | 131 | expect(result.result.current).toBe('John is 20 years old'); 132 | 133 | act(() => { 134 | person.setState('Sarah'); 135 | }); 136 | 137 | expect(result.result.current).toBe('Sarah is 20 years old'); 138 | 139 | act(() => { 140 | age.setState(30); 141 | }); 142 | 143 | expect(result.result.current).toBe('Sarah is 30 years old'); 144 | }); 145 | 146 | it('should update multiple dependencies at once', () => { 147 | const person = atom({ key: 'person', default: 'John' }); 148 | const age = atom({ key: 'age', default: 20 }); 149 | 150 | const personsAge = selector({ 151 | key: 'persons-age', 152 | get: ({ get }) => `${get(person)} is ${get(age)} years old`, 153 | }); 154 | 155 | const result = renderHook(() => useCoiledValue(personsAge)); 156 | 157 | expect(result.result.current).toBe('John is 20 years old'); 158 | 159 | act(() => { 160 | person.setState('Sarah'); 161 | age.setState(30); 162 | }); 163 | 164 | expect(result.result.current).toBe('Sarah is 30 years old'); 165 | }); 166 | 167 | it('should allow a chain of selectors as deps', () => { 168 | const person = atom({ key: 'person', default: 'John' }); 169 | const age = atom({ key: 'age', default: 20 }); 170 | 171 | const personsAge = selector({ 172 | key: 'persons-age', 173 | get: ({ get }) => `${get(person)} is ${get(age)} years old`, 174 | }); 175 | 176 | const personSentence = selector({ 177 | key: 'persons-age', 178 | get: ({ get }) => `My sentence is: "${get(personsAge)}"`, 179 | }); 180 | 181 | const result = renderHook(() => useCoiledValue(personSentence)); 182 | 183 | expect(result.result.current).toBe( 184 | 'My sentence is: "John is 20 years old"' 185 | ); 186 | 187 | act(() => { 188 | person.setState('Sarah'); 189 | age.setState(30); 190 | }); 191 | 192 | expect(result.result.current).toBe( 193 | 'My sentence is: "Sarah is 30 years old"' 194 | ); 195 | }); 196 | }); 197 | }); 198 | --------------------------------------------------------------------------------