├── 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 |
153 | updateItem(
154 | getNextId(),
155 | window.innerWidth / 2,
156 | window.innerHeight / 2
157 | )
158 | }
159 | >
160 | Add another box
161 |
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 |
--------------------------------------------------------------------------------