├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.js ├── codecov.yml ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── hooks │ │ ├── useExperiment.test.js │ │ └── useExperimentAsync.test.js │ ├── randomizer.test.js │ └── toggleExperiment.test.js ├── constants.ts ├── index.ts ├── interfaces.ts ├── randomizer.ts ├── toggleExperiment.ts ├── useExperiment.ts ├── useExperimentAsync.ts └── usePrevious.ts ├── tsconfig.json ├── tslint.json ├── types └── fbjs.d.ts └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | refs: 3 | container: &container 4 | docker: 5 | - image: node:8.16 6 | working_directory: ~/repo 7 | steps: 8 | - &Versions 9 | run: 10 | name: Versions 11 | command: node -v && npm -v && yarn -v 12 | - &Install 13 | run: 14 | name: Install Dependencies 15 | command: yarn install --pure-lockfile 16 | - &Build 17 | run: 18 | name: Build 19 | command: yarn build 20 | - &Lint 21 | run: 22 | name: Lint 23 | command: yarn lint 24 | - &Test 25 | run: 26 | name: Test 27 | command: yarn test --coverage && yarn codecov 28 | 29 | jobs: 30 | all: 31 | <<: *container 32 | steps: 33 | - checkout 34 | - *Versions 35 | - *Install 36 | - *Build 37 | - *Lint 38 | - *Test 39 | 40 | next: 41 | <<: *container 42 | steps: 43 | - checkout 44 | - *Versions 45 | - *Install 46 | - *Build 47 | - *Lint 48 | - *Test 49 | 50 | master: 51 | <<: *container 52 | steps: 53 | - checkout 54 | - *Versions 55 | - *Install 56 | - *Build 57 | - *Lint 58 | - *Test 59 | 60 | workflows: 61 | version: 2 62 | all: 63 | jobs: 64 | - all: 65 | context: global-env-vars 66 | filters: 67 | branches: 68 | ignore: 69 | - master 70 | - next 71 | next: 72 | jobs: 73 | - next: 74 | context: global-env-vars 75 | filters: 76 | branches: 77 | only: next 78 | master: 79 | jobs: 80 | - master: 81 | context: global-env-vars 82 | filters: 83 | branches: 84 | only: master 85 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # For most files 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lfit p 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | lib 3 | node_modules 4 | coverage 5 | *.log 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "fluid": false 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Julian Ustiyanovych 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |
4 |
5 | A/B 6 |
7 | Experiment Hooks 8 |
9 |
10 |

11 | 12 |
13 |
14 | 15 | npm package 16 | 17 | 18 | CircleCI master 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | ## Install 35 | 36 | Using npm: 37 | 38 | ```sh 39 | npm i ab-react-hook 40 | ``` 41 | 42 | or using yarn: 43 | 44 | ```sh 45 | yarn add ab-react-hook 46 | ``` 47 | 48 | 49 | ## When should I use A/B tests? 50 | 51 | It's a very good question to ask before start doing A/B tests. The simple answer would be - when the **sample size is statistically significant** and you have a **good traffic**. To dig deeper into numbers use [powercalculator](https://bookingcom.github.io/powercalculator/) made by [booking](https://bookingcom.github.io) to understand **how long would** it take you to run an A/B test and get a statistically significant result. 52 | 53 | 54 | ## ```useExperiment()``` [![][img-demo]](https://codesandbox.io/embed/ab-react-hook-playground-4crjn) 55 | 56 | 57 | - Define experiment variants and weights: 58 | ``` 59 | variants: [{ 60 | name: "control", weight: 50 61 | }, { 62 | name: "test", weight: 50 63 | }] 64 | ``` 65 | You can define *as many variants as you want* but it is recommended to keep **two** or max **three** variants for your experiment. 66 | - Get the variant and send it to your analytics (_google analytics_, _facebook analytics_ etc.), so you can **aggregate results in a single place and analyze it later**. 67 | 68 | 69 | ```js 70 | const AddToCartButtonExperiment = () => { 71 | const experimentConfig = { 72 | id: "3143106091", 73 | name: "add-to-cart-green", 74 | variants: [{ name: "control", weight: 50 }, { name: "test", weight: 50 }] 75 | enableForceExperiment: true 76 | }; 77 | 78 | const variant = useExperiment(experimentConfig) 79 | 80 | if (variant.name === "control") { 81 | return ; 82 | } else if (variant.name === "test") { 83 | return ; 84 | } 85 | } 86 | ``` 87 | 88 | 89 | ## ```useExperimentAsync()``` 90 | 91 | ```js 92 | const fetchVariant = () => { 93 | return new Promise((resolve, reject) => { 94 | setTimeout(() => { 95 | resolve("test"); 96 | }, 2000); 97 | }); 98 | } 99 | 100 | function AsyncAddToCartButtonExperiment() { 101 | const { variant, isLoading } = useExperimentAsync({ 102 | name: "exp1", 103 | fetchVariant, 104 | enableForceExperiment: true 105 | }); 106 | 107 | if (isLoading) { 108 | return
loading...
; 109 | } 110 | 111 | if (variant.name === "control") { 112 | return ; 113 | } else if (variant.name === "test") { 114 | return ; 115 | } 116 | } 117 | 118 | ``` 119 | 120 | ### Force experiment variant 121 | 122 | If `enableForceExperiment` flag set to `true` you can seamlessly **test** all possible variants of the particular experiment without changing the code. 123 | 124 | 125 | To **force** experiment variant add query parameter with **experiment id** and the **variant name**. 126 | 127 | - Force single experiment variant: 128 | ``` 129 | /?et=exp_id:exp_variant 130 | ``` 131 | 132 | - Force multiple experiments: 133 | ``` 134 | /?et=exp_1:exp_variant_id,exp_2:exp_variant_id 135 | ``` 136 | 137 | [img-demo]: https://img.shields.io/badge/demo-%20%20%20%F0%9F%9A%80-green.svg 138 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | env: { 15 | test: { 16 | plugins: ["dynamic-import-node"] 17 | }, 18 | production: { 19 | plugins: ["@babel/plugin-syntax-dynamic-import"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "70...100" 9 | 10 | status: 11 | project: yes 12 | patch: yes 13 | changes: no 14 | 15 | parsers: 16 | gcov: 17 | branch_detection: 18 | conditional: yes 19 | loop: yes 20 | method: no 21 | macro: no 22 | 23 | comment: 24 | layout: "header, diff" 25 | behavior: default 26 | require_changes: no 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["ts", "js", "json"], 3 | testEnvironment: "jest-environment-jsdom-global", 4 | coverageThreshold: { 5 | global: { 6 | branches: 83, 7 | functions: 100, 8 | lines: 100, 9 | statements: 100 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ab-react-hook", 3 | "version": "0.3.0", 4 | "description": "🧪A/B-Testing React Hook", 5 | "author": "Julian Ustiyanovych", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:ju1i4n/ab-react-hook.git" 10 | }, 11 | "keywords": [], 12 | "main": "lib/index.js", 13 | "types": "lib/index.d.ts", 14 | "scripts": { 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "lint": "tslint 'src/**/*.{ts,tsx}' -t verbose", 18 | "lint:fix": "tslint --project . --fix", 19 | "codecov": "codecov", 20 | "build": "rm -rf lib node_modules && yarn && tsc" 21 | }, 22 | "files": [ 23 | "lib/*" 24 | ], 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "yarn lint", 28 | "pre-push": "yarn test" 29 | } 30 | }, 31 | "dependencies": { 32 | "@types/lodash.isequal": "^4.5.5", 33 | "@types/url-parse": "^1.4.3", 34 | "fbjs": "1.0.0", 35 | "lodash.isequal": "^4.5.0", 36 | "react": "16.8.6", 37 | "react-dom": "16.8.6", 38 | "url-parse": "^1.4.7", 39 | "uuid": "^3.3.2" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.4.5", 43 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 44 | "@babel/preset-env": "^7.4.5", 45 | "@babel/preset-react": "^7.0.0", 46 | "@babel/preset-typescript": "^7.3.3", 47 | "@types/react": "^16.8.19", 48 | "babel-plugin-dynamic-import-node": "^2.2.0", 49 | "codecov": "^3.5.0", 50 | "husky": "^2.4.0", 51 | "i": "^0.3.6", 52 | "jest": "24.8.0", 53 | "jest-environment-jsdom-global": "^1.2.0", 54 | "npm": "^6.9.0", 55 | "react-hooks-testing-library": "^0.5.1", 56 | "react-test-renderer": "16.8.6", 57 | "ts-jest": "^24.0.2", 58 | "ts-node": "^8.2.0", 59 | "tslint": "^5.17.0", 60 | "tslint-config-prettier": "^1.18.0", 61 | "tslint-plugin-prettier": "^2.0.1", 62 | "typescript": "^3.5.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/__tests__/hooks/useExperiment.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook } from "react-hooks-testing-library"; 2 | 3 | import useExperiment from "../../useExperiment"; 4 | 5 | describe("useExperiment", () => { 6 | let experimentConfig; 7 | 8 | beforeEach(() => { 9 | experimentConfig = { 10 | id: "31410601021", 11 | name: "exp1", 12 | variants: [{ name: "control", weight: 99 }, { name: "test", weight: 1 }] 13 | }; 14 | }); 15 | 16 | it("should return weighted variant", () => { 17 | const hook = renderHook(() => useExperiment(experimentConfig)); 18 | const variant = hook.result.current; 19 | 20 | expect(variant).toEqual({ name: "control", weight: 99 }); 21 | }); 22 | 23 | it("should return forced variant", () => { 24 | jsdom.reconfigure({ 25 | url: "https://example.com?et=exp1:test" 26 | }); 27 | 28 | const hook = renderHook(() => 29 | useExperiment({ ...experimentConfig, enableForceExperiment: true }) 30 | ); 31 | 32 | const { name } = hook.result.current; 33 | 34 | expect(name).toEqual("test"); 35 | }); 36 | 37 | it("should return default config variant when forced exp name is wrong", () => { 38 | jsdom.reconfigure({ 39 | url: "https://example.com?et=exp_wrong:test" 40 | }); 41 | 42 | const hook = renderHook(() => 43 | useExperiment({ ...experimentConfig, enableForceExperiment: true }) 44 | ); 45 | 46 | const { name } = hook.result.current; 47 | 48 | expect(name).toEqual("control"); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/__tests__/hooks/useExperimentAsync.test.js: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "react-hooks-testing-library"; 2 | import useExperimentAsync from "../../useExperimentAsync"; 3 | 4 | describe("useExperimentAsync", () => { 5 | const fetchVariantFulfilled = () => 6 | new Promise(resolve => { 7 | setTimeout(() => { 8 | resolve("test"); 9 | }, 500); 10 | }); 11 | 12 | const fetchVariantRejected = () => 13 | new Promise((resolve, reject) => { 14 | setTimeout(() => { 15 | reject(); 16 | }, 500); 17 | }); 18 | 19 | it("when fetching variant set 'isLoading' to TRUE", () => { 20 | let hook; 21 | 22 | act(() => { 23 | hook = renderHook(() => 24 | useExperimentAsync({ 25 | name: "exp1", 26 | fetchVariant: fetchVariantFulfilled 27 | }) 28 | ); 29 | }); 30 | 31 | expect(hook.result.current.isLoading).toBeTruthy(); 32 | }); 33 | 34 | it("when variant is resolved set 'isLoading' to FALSE", async () => { 35 | let hook; 36 | 37 | act(() => { 38 | hook = renderHook(() => 39 | useExperimentAsync({ 40 | name: "exp1", 41 | fetchVariant: fetchVariantFulfilled 42 | }) 43 | ); 44 | }); 45 | 46 | await hook.waitForNextUpdate(); 47 | 48 | expect(hook.result.current.isLoading).toBeFalsy(); 49 | }); 50 | 51 | it("fetch variant with success - resolve", async () => { 52 | let hook; 53 | 54 | act(() => { 55 | hook = renderHook(() => 56 | useExperimentAsync({ 57 | name: "exp1", 58 | fetchVariant: fetchVariantFulfilled 59 | }) 60 | ); 61 | }); 62 | 63 | await hook.waitForNextUpdate(); 64 | 65 | expect(hook.result.current.isLoading).toBeFalsy(); 66 | expect(hook.result.current.variant).toEqual("test"); 67 | }); 68 | 69 | it("fetch variant with failure - reject", async () => { 70 | let hook; 71 | 72 | act(() => { 73 | hook = renderHook(() => 74 | useExperimentAsync({ 75 | name: "exp1", 76 | fetchVariant: fetchVariantRejected 77 | }) 78 | ); 79 | }); 80 | 81 | await hook.waitForNextUpdate(); 82 | 83 | expect(hook.result.current.isLoading).toBeFalsy(); 84 | expect(hook.result.current.variant).toEqual("noneVariant"); 85 | }); 86 | 87 | it("fetch forced variant from the url", async () => { 88 | jsdom.reconfigure({ 89 | url: "https://example.com?et=exp1:super_test" 90 | }); 91 | 92 | let hook; 93 | 94 | act(() => { 95 | hook = renderHook(() => 96 | useExperimentAsync({ 97 | name: "exp1", 98 | fetchVariant: fetchVariantFulfilled, 99 | enableForceExperiment: true 100 | }) 101 | ); 102 | }); 103 | 104 | await hook.waitForNextUpdate(); 105 | 106 | expect(hook.result.current.isLoading).toBeFalsy(); 107 | expect(hook.result.current.variant).toEqual("super_test"); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/__tests__/randomizer.test.js: -------------------------------------------------------------------------------- 1 | import uuidv4 from "uuid/v4"; 2 | 3 | import { generateWeightedVariant } from "../randomizer"; 4 | 5 | describe("Randomization algorithm for weighted variants:", () => { 6 | let n; 7 | beforeAll(() => { 8 | /* 9 | * Increase "n", to have larger destribution (e.g. 1K or 10K or 100K or 1M). 10 | * ⛔ ATTENTION ⛔: large number is not recommended because it would increase a time to 11 | * run all tests. 1M would take +/- 15sec. 12 | */ 13 | n = 100; 14 | }); 15 | 16 | describe("A/B: randomisation algorithm output should be reasonable.", () => { 17 | it("50/50 split", () => { 18 | const { isErrorRateLte1Precent } = calcABVariantWeights( 19 | { 20 | weightA: 50, 21 | weightB: 50 22 | }, 23 | n 24 | ); 25 | 26 | expect(isErrorRateLte1Precent).toBeTruthy(); 27 | }); 28 | 29 | it("60/40 split", () => { 30 | const { isErrorRateLte1Precent } = calcABVariantWeights( 31 | { 32 | weightA: 60, 33 | weightB: 40 34 | }, 35 | n 36 | ); 37 | 38 | expect(isErrorRateLte1Precent).toBeTruthy(); 39 | }); 40 | 41 | it("80/20 split", () => { 42 | const { isErrorRateLte1Precent } = calcABVariantWeights( 43 | { 44 | weightA: 80, 45 | weightB: 20 46 | }, 47 | n 48 | ); 49 | 50 | expect(isErrorRateLte1Precent).toBeTruthy(); 51 | }); 52 | 53 | it("99/1 split", () => { 54 | const { isErrorRateLte1Precent } = calcABVariantWeights( 55 | { 56 | weightA: 99, 57 | weightB: 1 58 | }, 59 | n 60 | ); 61 | 62 | expect(isErrorRateLte1Precent).toBeTruthy(); 63 | }); 64 | }); 65 | 66 | describe("A/B/C: randomisation algorithm output should be reasonable", () => { 67 | it("33/33/33 split", () => { 68 | const { isErrorRateLte1Precent } = calcABCVariantWeights( 69 | { 70 | weightA: 33, 71 | weightB: 33, 72 | weightC: 34 73 | }, 74 | n 75 | ); 76 | 77 | expect(isErrorRateLte1Precent).toBeTruthy(); 78 | }); 79 | 80 | it("60/20/20 split", () => { 81 | const { isErrorRateLte1Precent } = calcABCVariantWeights( 82 | { 83 | weightA: 60, 84 | weightB: 20, 85 | weightC: 20 86 | }, 87 | n 88 | ); 89 | 90 | expect(isErrorRateLte1Precent).toBeTruthy(); 91 | }); 92 | 93 | it("40/40/20 split", () => { 94 | const { isErrorRateLte1Precent } = calcABCVariantWeights( 95 | { 96 | weightA: 40, 97 | weightB: 40, 98 | weightC: 20 99 | }, 100 | n 101 | ); 102 | 103 | expect(isErrorRateLte1Precent).toBeTruthy(); 104 | }); 105 | 106 | it("80/10/10 split", () => { 107 | const { isErrorRateLte1Precent } = calcABCVariantWeights( 108 | { 109 | weightA: 40, 110 | weightB: 40, 111 | weightC: 20 112 | }, 113 | n 114 | ); 115 | 116 | expect(isErrorRateLte1Precent).toBeTruthy(); 117 | }); 118 | }); 119 | 120 | describe("return 'noneVariant' if configuration is wrong - only one variant provided.", () => { 121 | let variant = generateWeightedVariant({ 122 | id: `${uuidv4()}`, 123 | name: "experiment", 124 | variants: [ 125 | { 126 | name: "a", 127 | weight: 50 128 | } 129 | ] 130 | }); 131 | 132 | expect(variant).toEqual({ name: "noneVariant", weight: undefined }); 133 | }); 134 | 135 | describe("return 'noneVariant' if configuration is wrong - no variants provided.", () => { 136 | let variant = generateWeightedVariant({ 137 | id: `${uuidv4()}`, 138 | name: "experiment", 139 | variants: [ 140 | { 141 | name: "a", 142 | weight: 50 143 | }, 144 | { 145 | name: "a", 146 | weight: 51 147 | } 148 | ] 149 | }); 150 | 151 | expect(variant).toEqual({ name: "noneVariant", weight: undefined }); 152 | }); 153 | 154 | describe("return 'noneVariant' if configuration is wrong - total sum of all weights != 100.", () => { 155 | let variant = generateWeightedVariant({ 156 | id: `${uuidv4()}`, 157 | name: "experiment", 158 | variants: [] 159 | }); 160 | 161 | expect(variant).toEqual({ name: "noneVariant", weight: undefined }); 162 | }); 163 | 164 | describe("given the same input, randomizer should always return deterministic result - same variant", () => { 165 | const assignedVariantWithMultipleSessions = []; 166 | 167 | for (let index = 0; index < 100; index++) { 168 | let variant = generateWeightedVariant({ 169 | id: `user_id_123456789`, 170 | name: "experiment", 171 | variants: [ 172 | { 173 | name: "test", 174 | weight: 50 175 | }, 176 | { 177 | name: "control", 178 | weight: 50 179 | } 180 | ] 181 | }); 182 | 183 | assignedVariantWithMultipleSessions.push(variant); 184 | } 185 | 186 | const isVariantDeterministic = assignedVariantWithMultipleSessions.every( 187 | ({ name, weight }) => name === "control" && weight === 50 188 | ); 189 | 190 | expect(isVariantDeterministic).toBeTruthy(); 191 | }); 192 | }); 193 | 194 | const calcABVariantWeights = ({ weightA, weightB }, n) => { 195 | const assignedVariants = []; 196 | 197 | for (let index = 0; index < n; index++) { 198 | let variant = generateWeightedVariant({ 199 | id: `${uuidv4()}`, 200 | name: "experiment", 201 | variants: [ 202 | { 203 | name: "a", 204 | weight: weightA 205 | }, 206 | { 207 | name: "b", 208 | weight: weightB 209 | } 210 | ] 211 | }); 212 | 213 | assignedVariants.push(variant.name); 214 | } 215 | 216 | const vAFraction = assignedVariants.filter(v => v === "a").length / n; 217 | const vBFraction = assignedVariants.filter(v => v === "b").length / n; 218 | 219 | const weight = Math.sqrt((weightA * weightB) / n); 220 | 221 | // with 99.7% probability for Variant A: p - 3 * s < f < p + 3 * s 222 | const p99A = 223 | weightA - 3 * weight < vAFraction < weightA < weightA + 3 * weight; 224 | // with 99.7% probability for Variant B: p - 3 * s < f < p + 3 * s 225 | const p99B = 226 | weightB - 3 * weight < vBFraction < weightB < weightB + 3 * weight; 227 | 228 | // Calculate error rate for variant A. error = (approx - exact) / exact; 229 | const vAErrorRate = (100 - vAFraction) / 100; 230 | 231 | // Calculate error rate for variant B. error = (approx - exact) / exact; 232 | const vBErrorRate = (100 - vBFraction) / 100; 233 | 234 | // Error rate must be less or equal to 1% 235 | const isErrorRateLte1Precent = vAErrorRate <= 1 && vBErrorRate <= 1; 236 | 237 | return { 238 | isErrorRateLte1Precent, 239 | vAFraction, 240 | vBFraction, 241 | vAErrorRate, 242 | vBErrorRate, 243 | p99A, 244 | p99B 245 | }; 246 | }; 247 | 248 | const calcABCVariantWeights = ({ weightA, weightB, weightC }, n) => { 249 | const assignedVariants = []; 250 | 251 | for (let index = 0; index < n; index++) { 252 | let variant = generateWeightedVariant({ 253 | id: `${uuidv4()}`, 254 | name: "experiment", 255 | variants: [ 256 | { 257 | name: "a", 258 | weight: weightA 259 | }, 260 | { 261 | name: "b", 262 | weight: weightB 263 | }, 264 | { 265 | name: "c", 266 | weight: weightC 267 | } 268 | ] 269 | }); 270 | 271 | assignedVariants.push(variant.name); 272 | } 273 | 274 | const vAFraction = assignedVariants.filter(v => v === "a").length / n; 275 | const vBFraction = assignedVariants.filter(v => v === "b").length / n; 276 | const vCFraction = assignedVariants.filter(v => v === "c").length / n; 277 | 278 | const weight = Math.sqrt((weightA * weightB * weightC) / n); 279 | 280 | // with 99.7% probability for Variant A: p - 3 * s < f < p + 3 * s 281 | const p99A = 282 | weightA - 3 * weight < vAFraction < weightA < weightA + 3 * weight; 283 | // with 99.7% probability for Variant B: p - 3 * s < f < p + 3 * s 284 | const p99B = 285 | weightB - 3 * weight < vBFraction < weightB < weightB + 3 * weight; 286 | // with 99.7% probability for Variant C: p - 3 * s < f < p + 3 * s 287 | const p99C = 288 | weightC - 3 * weight < vCFraction < weightC < weightC + 3 * weight; 289 | 290 | // Calculate error rate for variant A. error = (approx - exact) / exact; 291 | const vAErrorRate = (100 - vAFraction) / 100; 292 | // Calculate error rate for variant B. error = (approx - exact) / exact; 293 | const vBErrorRate = (100 - vBFraction) / 100; 294 | // Calculate error rate for variant C. error = (approx - exact) / exact; 295 | const vCErrorRate = (100 - vCFraction) / 100; 296 | 297 | // Error rate must be less or equal to 1% 298 | const isErrorRateLte1Precent = 299 | vAErrorRate <= 1 && vBErrorRate <= 1 && vCErrorRate <= 1; 300 | 301 | return { 302 | isErrorRateLte1Precent, 303 | vAFraction, 304 | vBFraction, 305 | vCFraction, 306 | vAErrorRate, 307 | vBErrorRate, 308 | vCErrorRate, 309 | p99A, 310 | p99B, 311 | p99C 312 | }; 313 | }; 314 | -------------------------------------------------------------------------------- /src/__tests__/toggleExperiment.test.js: -------------------------------------------------------------------------------- 1 | import { getBrowserQueryExperimentNames } from "../toggleExperiment"; 2 | 3 | describe("Force experiment variant", () => { 4 | it("should return forced variant when defined in a querystring", () => { 5 | jsdom.reconfigure({ 6 | url: "https://example.com?et=exp1:test" 7 | }); 8 | 9 | expect(window.location.search).toEqual("?et=exp1:test"); 10 | 11 | const forcedVariants = getBrowserQueryExperimentNames(); 12 | const variant = forcedVariants["exp1"]; 13 | 14 | expect(variant).toEqual("test"); 15 | }); 16 | 17 | it("should return undefined variant when variant is not forced", () => { 18 | jsdom.reconfigure({ 19 | url: "https://example.com" 20 | }); 21 | 22 | const forcedVariants = getBrowserQueryExperimentNames(); 23 | 24 | expect(forcedVariants).toEqual({}); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const NONE_VARIANT = "noneVariant"; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import useExperiment from "./useExperiment"; 2 | import useExperimentAsync from "./useExperimentAsync"; 3 | 4 | export { 5 | useExperiment, 6 | useExperimentAsync 7 | }; 8 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Variant { 2 | name: string; 3 | weight: number; 4 | } 5 | 6 | export interface NoneVariant { 7 | name: string; 8 | } 9 | export interface ExperimentConfigAsync { 10 | name: string; 11 | fetchVariant: (...args: any[] | []) => Promise; 12 | enableForceExperiment?: boolean; 13 | } 14 | 15 | export interface ExperimentResultAsync { 16 | variant: string; 17 | isLoading?: boolean; 18 | } 19 | 20 | export interface ExperimentConfig { 21 | id: string; 22 | name: string; 23 | variants: Variant[]; 24 | enableForceExperiment?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /src/randomizer.ts: -------------------------------------------------------------------------------- 1 | import crc32 from "fbjs/lib/crc32"; 2 | import { NONE_VARIANT } from "./constants"; 3 | import { ExperimentConfig, Variant } from "./interfaces"; 4 | 5 | export const generateWeightedVariant = ( 6 | options: ExperimentConfig, 7 | logger: any = console 8 | ): Variant => { 9 | const { id, name, variants } = options; 10 | 11 | if (variants.length < 2) { 12 | /* 13 | * ✅Experiment configuration check. 14 | * 15 | * At least two variants should be provided (e.g. A and B). 16 | */ 17 | logger.warn( 18 | `WARN: There are should be at least TWO experiment variants and you have: ${ 19 | variants.length 20 | }.` 21 | ); 22 | 23 | return { name: NONE_VARIANT, weight: undefined }; 24 | } 25 | 26 | const weightSum = variants.reduce((acc, variant) => acc + variant.weight, 0); 27 | 28 | if (weightSum !== 100) { 29 | /* 30 | * ✅Experiment configuration check. 31 | * 32 | * The total sum of variants weight should be equal to 100. 33 | * If the sum is not equal to 100, return noneVariant to indicate 34 | * experiment missconfiguration. 35 | */ 36 | const sign = weightSum < 100 ? "less" : "more"; 37 | 38 | logger.warn( 39 | `WARN: Your total variant weight is ${sign} than 100.` + 40 | " " + 41 | "Make sure your experiemnt configuration is correct." 42 | ); 43 | 44 | return { name: NONE_VARIANT, weight: undefined }; 45 | } 46 | 47 | const bucket = Math.abs(crc32(`${id}${name}`) % weightSum); 48 | const variant = findVariant(0, bucket, variants, logger); 49 | 50 | return { name: variant.name, weight: variant.weight }; 51 | }; 52 | 53 | const findVariant = ( 54 | currentBucket: number, 55 | initialBucket: number, 56 | variants: Variant[], 57 | logger: any = console 58 | ): Variant => { 59 | const variant = variants.shift() as Variant; 60 | 61 | return initialBucket < currentBucket + variant.weight || !variants.length 62 | ? variant 63 | : findVariant(currentBucket + variant.weight, initialBucket, variants, logger); 64 | }; 65 | -------------------------------------------------------------------------------- /src/toggleExperiment.ts: -------------------------------------------------------------------------------- 1 | import URL from "url-parse"; 2 | 3 | interface Experiment { 4 | [key: string]: string | undefined; 5 | } 6 | 7 | const getQueryExperiments = (et: string): Experiment => 8 | et 9 | ? et.split(",").reduce((exps, exp) => { 10 | const [expId, expVariant] = exp.split(":"); 11 | exps[expId] = expVariant; 12 | return exps; 13 | }, {} as Experiment) 14 | : {}; 15 | 16 | export const getBrowserQueryExperimentNames = (search?: string): Experiment => { 17 | search = typeof window === "undefined" ? "" : window.location.search; 18 | const { et = "" } = URL.qs.parse(search) as Record; 19 | return getQueryExperiments(et); 20 | }; 21 | -------------------------------------------------------------------------------- /src/useExperiment.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import isEqual from "lodash.isequal"; 4 | 5 | import usePrevious from "./usePrevious"; 6 | 7 | import { ExperimentConfig, Variant } from "./interfaces"; 8 | import { generateWeightedVariant } from "./randomizer"; 9 | import { getBrowserQueryExperimentNames } from "./toggleExperiment"; 10 | 11 | const useExperiment = ( 12 | config: ExperimentConfig, 13 | logger: any = console, 14 | ): Variant | undefined => { 15 | 16 | const defaultVariant = { 17 | name: undefined, 18 | weight: undefined 19 | } as Variant; 20 | 21 | const [variant, setVariant] = useState(defaultVariant); 22 | 23 | const { id, name, variants, enableForceExperiment } = config; 24 | 25 | const prevConfig = usePrevious([ id, name, variants, enableForceExperiment ]); 26 | const prevVariant = usePrevious([ variant ]); 27 | 28 | useEffect(() => { 29 | if ( 30 | isEqual(prevConfig, [ 31 | id, 32 | name, 33 | variants, 34 | enableForceExperiment 35 | ]) && 36 | !isEqual(prevVariant, [defaultVariant]) 37 | ) { 38 | return; 39 | } 40 | 41 | if (enableForceExperiment) { 42 | const forcedExperiments = getBrowserQueryExperimentNames(); 43 | const forcedVariant = forcedExperiments[name]; 44 | 45 | if (forcedVariant) { 46 | setVariant({ name: forcedVariant, weight: undefined }); 47 | return; 48 | } 49 | } 50 | 51 | const variant = generateWeightedVariant( 52 | { 53 | id, 54 | name, 55 | variants 56 | } as ExperimentConfig, 57 | logger 58 | ); 59 | 60 | setVariant(variant); 61 | }); 62 | 63 | return variant; 64 | }; 65 | 66 | export default useExperiment; 67 | -------------------------------------------------------------------------------- /src/useExperimentAsync.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { NONE_VARIANT } from "./constants"; 3 | import { ExperimentConfigAsync, ExperimentResultAsync } from "./interfaces"; 4 | import { getBrowserQueryExperimentNames } from "./toggleExperiment"; 5 | 6 | export default function useExperimentAsync( 7 | config: ExperimentConfigAsync, 8 | logger: any = console 9 | ): ExperimentResultAsync { 10 | const [variant, setVariant] = useState(""); 11 | const [isLoading, setLoading] = useState(false); 12 | 13 | const { name, fetchVariant, enableForceExperiment } = config; 14 | 15 | useEffect(() => { 16 | if (enableForceExperiment) { 17 | (async () => { 18 | const forcedExperiments = getBrowserQueryExperimentNames(); 19 | const forcedVariant = await Promise.resolve(forcedExperiments[name]); 20 | 21 | if (forcedVariant) { 22 | setLoading(true); 23 | setVariant(forcedVariant); 24 | setLoading(false); 25 | return; 26 | } 27 | })(); 28 | } 29 | 30 | (async () => { 31 | try { 32 | setLoading(true); 33 | const variant = await fetchVariant(); 34 | setVariant(variant); 35 | setLoading(false); 36 | } catch (err) { 37 | logger.error(err); 38 | setLoading(false); 39 | setVariant(NONE_VARIANT); 40 | } 41 | })(); 42 | }, [fetchVariant, enableForceExperiment, logger]); 43 | 44 | return { variant, isLoading }; 45 | } 46 | -------------------------------------------------------------------------------- /src/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const usePrevious = (state: T): T | undefined => { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = state; 8 | }); 9 | 10 | return ref.current; 11 | }; 12 | 13 | export default usePrevious; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { "*": ["types/*"] }, 5 | "declaration": true, 6 | "declarationDir": "./lib", 7 | "target": "es2017", 8 | "module": "commonjs", 9 | "sourceMap": false, 10 | "pretty": true, 11 | "rootDir": "./src", 12 | "outDir": "./lib", 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noImplicitReturns": true, 17 | "moduleResolution": "node", 18 | "typeRoots": ["./types", "./node_modules/@types"], 19 | "esModuleInterop": true 20 | }, 21 | "exclude": ["node_modules", "lib", "**/__tests__/**/*"] 22 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "rules": { 6 | "arrow-parens": false, 7 | "no-shadowed-variable": false, 8 | "interface-name": false, 9 | "trailing-comma": false, 10 | "object-literal-sort-keys": false, 11 | "max-classes-per-file": [false, 0] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /types/fbjs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'fbjs/lib/crc32'; --------------------------------------------------------------------------------