├── .github └── workflows │ └── prepublish.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── create-facade.test.tsx ├── create-facade.tsx └── index.ts ├── tsconfig.json └── vitest.config.ts /.github/workflows/prepublish.yml: -------------------------------------------------------------------------------- 1 | name: "prepublish" 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | run: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | - run: npm install 13 | - run: npm run prepublishOnly 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | build/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021-2023 (c) Gabe Scholz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Facade 2 | 3 | Write components with placeholders for hooks. 4 | 5 | An experimental 2kb library that uses [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) and TypeScript to create a strongly typed [facade](https://en.wikipedia.org/wiki/Facade_pattern) for your React hooks. 6 | 7 | - **Dependency inversion between components and hooks:** Build components that rely on hooks that do not have a particular implementation. 8 | - **Works with all of your existing hooks:** Extract hooks to the top level of your program and replace them with a facade. 9 | - **Simplified component testing:** You were already testing your hooks anyway (right?), so why test them again? Focus on rendering outcomes rather than bungling around with a cumbersome setup for test cases. 10 | 11 | The hooks implementation is injected via React context so that they can be changed between views or testing. It is dependency injection for hooks that does not require higher-order functions/Components. 12 | 13 | ## Example 14 | 15 | Consider an application which describes its data-fetching layer in the UI with the following list of hooks, 16 | 17 | ```ts 18 | function useCurrentUser(): User; 19 | function usePostById(id: string): { loading: boolean; error?: Error; data?: Post }; 20 | function useCreateNewPost(): (postData: PostData) => Promise; 21 | // etc... 22 | ``` 23 | 24 | Given this interface, a developer can reliably use these hooks without knowing anything about their underlying _implementation_ (leaving aside questions of fidelity). That is to say: the developer _only_ cares about the _interface_. The problem, however, is that by inlining a hook as part of the React component, the _implementation_ cannot be ignored. For example, a component `UserProfile` may have the following definition, 25 | 26 | ```ts 27 | // user-profile.tsx 28 | 29 | import React from "react"; 30 | import { userCurrentUser } from "./hooks"; 31 | 32 | export function UserProfile() { 33 | const user = useCurrentUser(); 34 | 35 | // ... render user profile 36 | } 37 | ``` 38 | 39 | The developer of this component may not care about the implementation of `useCurrentUser`, but the tests sure do! If under the hood `useCurrentUser` calls the react-redux `useSelector`, then `UserProfile` depends directly on a global Redux store. Moreover, any component using `UserProfile` also has this dependency. The coupling between the store and component tree is hard-coded by this hook. Yikes! 40 | 41 | Consider the same problem where the _implementation_ can be completely ignored and replaced by dependency injection. We define the same interface using `createFacade`, 42 | 43 | ```ts 44 | // facade.ts 45 | 46 | import { createFacade } from "react-facade"; 47 | 48 | type Hooks = { 49 | useCurrentUser(): User; 50 | usePostById(id: string): { loading?: boolean; error?: Error; data?: Post }; 51 | useCreateNewPost(): (postData: PostData) => Promise; 52 | // ... 53 | }; 54 | 55 | // no implementation! 56 | export const [hooks, ImplementationProvider] = createFacade(); 57 | ``` 58 | 59 | And then the `UserProfile` becomes, 60 | 61 | ```ts 62 | // user-profile.tsx 63 | 64 | import React from "react"; 65 | import { hooks } from "./facade"; 66 | 67 | export function UserProfile() { 68 | const user = hooks.useCurrentUser(); 69 | 70 | // ... render user profile 71 | } 72 | ``` 73 | 74 | This time, we don't care about the _implementation_ because there literally isn't one. Depending on the environment, it can be injected by passing a different _implementation_ to `ImplementationProvider`. 75 | 76 | At the application level, we might use `useSelector` to fetch the current user from our store, 77 | 78 | ```tsx 79 | // app.tsx 80 | 81 | import React from "react"; 82 | import { useSelector } from "react-redux"; 83 | import { ImplementationProvider } from "./facade"; 84 | // ... 85 | 86 | const implementation = { 87 | useCurrentUser(): User { 88 | return useSelector(getCurrentUser); 89 | }, 90 | 91 | // ... 92 | }; 93 | 94 | return ( 95 | 96 | 97 | 98 | ); 99 | ``` 100 | 101 | While in a test environment, we can return a stub user so long as it matches our _interface_, 102 | 103 | ```tsx 104 | // user-profile.test.tsx 105 | 106 | import React from "react"; 107 | import { render } from "@testing-library/react"; 108 | import { ImplementationProvider } from "./facade"; 109 | // ... 110 | 111 | test("some thing", () => { 112 | const implementation = { 113 | useCurrentUser(): User { 114 | return { 115 | id: "stub", 116 | name: "Gabe", 117 | // ... 118 | }; 119 | }, 120 | 121 | // ... 122 | }; 123 | 124 | const result = render( 125 | // What is `__UNSAFE_Test_Partial`? See API section 126 | 127 | 128 | 129 | ); 130 | 131 | // ... 132 | }); 133 | ``` 134 | 135 | We are programming purely toward the _interface_ and _NOT_ the _implementation_! 136 | 137 | Again, consider how this might simplify testing a component that relied on this hook, 138 | 139 | ```ts 140 | function usePostById(id: string): { loading: boolean; error?: Error; data?: Post }; 141 | ``` 142 | 143 | Testing different states is simply a matter of declaratively passing in the right one, 144 | 145 | ```ts 146 | // post.test.tsx 147 | 148 | const loadingImplementation = { 149 | usePostById(id: string) { 150 | return { 151 | loading: true, 152 | }; 153 | }, 154 | }; 155 | 156 | const errorImplementation = { 157 | usePostById(id: string) { 158 | return { 159 | loading: false, 160 | error: new Error("uh oh!"), 161 | }; 162 | }, 163 | }; 164 | 165 | // ... 166 | 167 | test("shows the loading spinner", () => { 168 | const result = render( 169 | 170 | 171 | 172 | ); 173 | 174 | // ... 175 | }); 176 | 177 | test("displays an error", () => { 178 | const result = render( 179 | 180 | 181 | 182 | ); 183 | 184 | // ... 185 | }); 186 | ``` 187 | 188 | ## API 189 | 190 | ### `createFacade` 191 | 192 | ```ts 193 | type Wrapper = React.JSXElementConstructor<{ children: React.ReactElement }>; 194 | 195 | function createFacade( 196 | options?: Partial<{ displayName: string; strict: boolean; wrapper: Wrapper }> 197 | ): [Proxy, ImplementationProvider]; 198 | ``` 199 | 200 | Takes a type definition `T` - which must be an object where each member is a function - and returns the tuple of the interface `T` (via a Proxy) and an `ImplementationProvider`. The developer provides the real implementation of the interface through the Provider. 201 | 202 | The `ImplementationProvider` does not collide with other `ImplementationProvider`s created by other `createFacade` calls, so you can make as many of these as you need. 203 | 204 | #### Options 205 | 206 | | option | type | default | details | 207 | | ----------- | -------- | -------------------------- | ------------------------------------------------------------------------ | 208 | | displayName | string | "Facade" | The displayName for debugging with React Devtools . | 209 | | strict | boolean | true | When `true` does not allow the implementation to change between renders. | 210 | | wrapper | React.FC | ({ children }) => children | A wrapper component that can be used to wrap the ImplementationProvider. | 211 | 212 | ### `ImplementationProvider` 213 | 214 | Accepts a prop `implementation: T` that implements the interface defined in `createFacade()`. 215 | 216 | ```ts 217 | const implementation = { 218 | useCurrentUser(): User { 219 | return useSelector(getCurrentUser); 220 | }, 221 | 222 | // ... 223 | }; 224 | 225 | return ( 226 | 227 | 228 | 229 | ); 230 | ``` 231 | 232 | ### `ImplementationProvider.__UNSAFE_Test_Partial` 233 | 234 | Used for partially implementing the interface when you don't need to implement the whole thing but still want it to type-check (tests?). For the love of God, please do not use this outside of tests... 235 | 236 | ```tsx 237 | 238 | 239 | 240 | ``` 241 | 242 | ## Installing 243 | 244 | ``` 245 | npm install react-facade 246 | ``` 247 | 248 | ## Asked Questions 249 | 250 | ### Why not just use `jest.mock?` 251 | 252 | Mocking at the module level has the notable downside that type safety is optional. The onus is on the developer to ensure that the mock matches the actual interface. While stubbing with a _static_ language is dangerous enough because it removes critical interactions between units of code, a _dynamic_ language is even worse because changes to the real implementation interface (without modifications to the stub) can result in runtime type errors in production. Choosing to forgo the type check means that you might as well be writing JavaScript. 253 | 254 | ### Can I use this with plain JavaScript? 255 | 256 | It is _really_ important that this library is used with TypeScript. It's a trick to use a Proxy object in place of the real implementation when calling `createFacade`, so nothing stops you from calling a function that does not exist. Especially bad would be destructuring so your fake hook could be used elsewhere in the program. 257 | 258 | ```js 259 | // hooks.js 260 | 261 | export const { useSomethingThatDoesNotExist } = hooks; 262 | ``` 263 | 264 | ```js 265 | // my-component.jsx 266 | 267 | import { useSomethingThatDoesNotExist } from "./hooks"; 268 | 269 | const MyComponent = () => { 270 | const value = useSomethingThatDoesNotExist(); // throw new Error('oopsie-doodle!') 271 | }; 272 | ``` 273 | 274 | The only thing preventing you from cheating like this is good ol' TypeScript. 275 | 276 | ### Is this safe to use? 277 | 278 | Popular libraries like [`immer`](https://github.com/immerjs/immer) use the same trick of wrapping data `T` in a `Proxy` and present it as `T`, so I don't think you should be concerned. Proxy has [~95% browser support](https://caniuse.com/proxy). 279 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-facade", 3 | "description": "A simple library that uses Proxy and TypeScript to create a strongly typed facade for your React hooks.", 4 | "version": "0.5.0", 5 | "main": "build/index.js", 6 | "module": "build/index.mjs", 7 | "types": "build/index.d.ts", 8 | "license": "MIT", 9 | "author": "Gabe Scholz ", 10 | "homepage": "https://github.com/garbles/react-facade", 11 | "files": [ 12 | "build", 13 | "src", 14 | "LICENSE", 15 | "README.md" 16 | ], 17 | "peerDependencies": { 18 | "react": "^18.2.0" 19 | }, 20 | "dependencies": { 21 | "invariant": "^2.2.4" 22 | }, 23 | "devDependencies": { 24 | "@testing-library/react": "^13.4.0", 25 | "@types/invariant": "^2.2.35", 26 | "@types/react": "^18.0.26", 27 | "@types/react-dom": "^18.0.10", 28 | "happy-dom": "^8.1.1", 29 | "npm-check-updates": "^16.6.2", 30 | "npm-run-all": "^4.1.5", 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "tsup": "^6.5.0", 34 | "typescript": "^4.9.4", 35 | "vite": "^4.0.3", 36 | "vitest": "^0.26.2" 37 | }, 38 | "scripts": { 39 | "test": "vitest", 40 | "build": "tsup src/index.ts --clean --outDir build --dts --sourcemap --format esm,cjs", 41 | "typecheck": "tsc", 42 | "prepublishOnly": "run-s test typecheck build", 43 | "bump": "ncu -u" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/create-facade.test.tsx: -------------------------------------------------------------------------------- 1 | import { test, describe, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { act, render, renderHook } from "@testing-library/react"; 3 | import React from "react"; 4 | import { createFacade, ImplementationProvider } from "./create-facade"; 5 | 6 | const createWrapper = 7 | (impl: I, Provider: ImplementationProvider) => 8 | (props) => { 9 | return {props.children}; 10 | }; 11 | 12 | test("creates an implementation", () => { 13 | type IFace = { 14 | useAString(): string; 15 | useANumber(): number; 16 | 17 | nested: { 18 | useABoolean(): boolean; 19 | }; 20 | }; 21 | 22 | const impl = { 23 | useAString() { 24 | return "some string"; 25 | }, 26 | 27 | useANumber() { 28 | return 123; 29 | }, 30 | 31 | nested: { 32 | useABoolean() { 33 | return true; 34 | }, 35 | }, 36 | }; 37 | 38 | const [hooks, Provider] = createFacade(); 39 | const wrapper = createWrapper(impl, Provider); 40 | 41 | const { result: resultA } = renderHook(() => hooks.useAString(), { wrapper }); 42 | const { result: resultB } = renderHook(() => hooks.useANumber(), { wrapper }); 43 | const { result: resultC } = renderHook(() => hooks.nested.useABoolean(), { wrapper }); 44 | 45 | expect(resultA.current).toEqual("some string"); 46 | expect(resultB.current).toEqual(123); 47 | expect(resultC.current).toEqual(true); 48 | }); 49 | 50 | test("implementations can use other hooks", () => { 51 | vi.useFakeTimers(); 52 | 53 | type IFace = { 54 | useCounter(interval: number): number; 55 | }; 56 | 57 | const impl: IFace = { 58 | useCounter(interval: number) { 59 | const [count, increment] = React.useReducer((count) => count + 1, 0); 60 | 61 | React.useEffect(() => { 62 | const intervalId = setInterval(increment, interval); 63 | return () => clearInterval(intervalId); 64 | }, [interval, increment]); 65 | 66 | return count; 67 | }, 68 | }; 69 | 70 | const [hooks, Provider] = createFacade(); 71 | const wrapper = createWrapper(impl, Provider); 72 | 73 | const { result } = renderHook(() => hooks.useCounter(1000), { wrapper }); 74 | 75 | expect(result.current).toEqual(0); 76 | 77 | act(() => { 78 | vi.advanceTimersByTime(3000); 79 | }); 80 | 81 | expect(result.current).toEqual(3); 82 | }); 83 | 84 | describe("using other contexts", () => { 85 | type IFace = { 86 | useAString(): string; 87 | }; 88 | 89 | const [hooks, Provider] = createFacade(); 90 | const SomeStringContext = React.createContext("some string"); 91 | 92 | const impl: IFace = { 93 | useAString() { 94 | return React.useContext(SomeStringContext); 95 | }, 96 | }; 97 | 98 | const wrapper = createWrapper(impl, Provider); 99 | 100 | test("implementations can use other contexts", () => { 101 | const { result } = renderHook(() => hooks.useAString(), { wrapper }); 102 | 103 | expect(result.current).toEqual("some string"); 104 | }); 105 | 106 | test("the order of the context does not matter as long as the implementation is called inside both.", () => { 107 | const wrapperWithContextOutside = (props: any) => { 108 | return {wrapper(props)}; 109 | }; 110 | 111 | const { result: resultA } = renderHook(() => hooks.useAString(), { wrapper: wrapperWithContextOutside }); 112 | 113 | expect(resultA.current).toEqual("some other string"); 114 | 115 | const wrapperWithContextInside = (props: any) => { 116 | return wrapper({ 117 | children: {props.children}, 118 | }); 119 | }; 120 | 121 | const { result: resultB } = renderHook(() => hooks.useAString(), { wrapper: wrapperWithContextInside }); 122 | 123 | expect(resultB.current).toEqual("yet another string"); 124 | }); 125 | 126 | test("using the wrapper option", () => { 127 | const WrapperWithContextOutside = (props: any) => { 128 | return {props.children}; 129 | }; 130 | 131 | const [hooks, Provider] = createFacade({ wrapper: WrapperWithContextOutside }); 132 | const SomeStringContext = React.createContext("some string"); 133 | 134 | const impl: IFace = { 135 | useAString() { 136 | return React.useContext(SomeStringContext); 137 | }, 138 | }; 139 | 140 | const wrapper = createWrapper(impl, Provider); 141 | 142 | const { result: resultA } = renderHook(() => hooks.useAString(), { wrapper }); 143 | 144 | expect(resultA.current).toEqual("some other string"); 145 | }); 146 | }); 147 | 148 | test("destructuring always returns the same reference", () => { 149 | type IFace = { 150 | useCurrentUser(): { id: string; name: string }; 151 | nested: { 152 | useANestedValue(): string; 153 | }; 154 | }; 155 | 156 | const [hooks] = createFacade(); 157 | 158 | expect(hooks.useCurrentUser).toBe(hooks.useCurrentUser); 159 | expect(hooks.nested.useANestedValue).toBe(hooks.nested.useANestedValue); 160 | }); 161 | 162 | describe("errors", () => { 163 | let error: typeof console.error; 164 | 165 | beforeEach(() => { 166 | error = console.error; 167 | console.error = vi.fn(); 168 | }); 169 | 170 | afterEach(() => { 171 | console.error = error; 172 | }); 173 | 174 | test("throws an error when provider is not used", () => { 175 | type IFace = { 176 | useCurrentUser(): { id: string; name: string }; 177 | }; 178 | 179 | const [hooks] = createFacade({ displayName: "Oopsie" }); 180 | 181 | expect(() => renderHook(() => hooks.useCurrentUser())).toThrowError( 182 | new Error('Component using "useCurrentUser" must be wrapped in provider ImplementationProviderContext(Oopsie)') 183 | ); 184 | }); 185 | 186 | test("throws an error when implementation does not include hook", () => { 187 | type IFace = { 188 | useCurrentUser(): { id: string; name: string }; 189 | }; 190 | 191 | const impl = {} as any; 192 | 193 | const [hooks, Provider] = createFacade({ displayName: "Oopsie" }); 194 | const wrapper = createWrapper(impl, Provider); 195 | 196 | expect(() => renderHook(() => hooks.useCurrentUser(), { wrapper })).toThrowError( 197 | new Error('ImplementationProviderContext(Oopsie) does not provide a hook named "useCurrentUser"') 198 | ); 199 | }); 200 | 201 | test("throws an error if ImplementationProvider is inside another ImplementationProvider", () => { 202 | type IFace = {}; 203 | 204 | const [hooks, Provider] = createFacade({ displayName: "NestedRoots" }); 205 | 206 | expect(() => 207 | render( 208 | 209 | 210 | 211 | ) 212 | ).toThrowError( 213 | new Error( 214 | "ImplementationProvider(NestedRoots) should not be rendered inside of another ImplementationProvider(NestedRoots)." 215 | ) 216 | ); 217 | }); 218 | }); 219 | 220 | test("type error when member is not a function", () => { 221 | interface IFace { 222 | useA: string; 223 | useB(): number; 224 | 225 | nested: { 226 | useC: boolean; 227 | useD(): boolean; 228 | }; 229 | } 230 | 231 | const [hooks] = createFacade(); 232 | 233 | // @ts-expect-error 234 | expect(typeof hooks.useA).toBe("function"); 235 | 236 | // not an error 237 | expect(typeof hooks.useB).toBe("function"); 238 | 239 | // @ts-expect-error 240 | expect(typeof hooks.nested.useC).toBe("function"); 241 | 242 | // not an error 243 | expect(typeof hooks.nested.useD).toBe("function"); 244 | }); 245 | 246 | test("type error when interface is not an object", () => { 247 | // @ts-expect-error 248 | createFacade(); 249 | }); 250 | 251 | test("various type checking errors", () => { 252 | type IFace = { 253 | useA: string; 254 | }; 255 | 256 | const impl: IFace = { 257 | useA: "400", 258 | }; 259 | 260 | const [hooks, Provider] = createFacade(); 261 | 262 | const wrapper = createWrapper(impl, Provider); 263 | 264 | expect(() => 265 | renderHook( 266 | () => { 267 | // @ts-expect-error 268 | hooks.useA(); 269 | }, 270 | { wrapper } 271 | ) 272 | ).toThrowError(new Error('ImplementationProviderContext(Facade) provides a value "useA" but it is not a function.')); 273 | }); 274 | 275 | test("hooks can reference other hooks in the implementation", () => { 276 | type IFace = { 277 | useCurrentUser(): { id: string; name: string }; 278 | useUserId(): string; 279 | }; 280 | 281 | const [hooks, Provider] = createFacade(); 282 | const { useUserId } = hooks; 283 | 284 | const impl = { 285 | useCurrentUser() { 286 | return { 287 | id: "12345", 288 | name: "Gabe", 289 | }; 290 | }, 291 | 292 | useUserId() { 293 | const user = this.useCurrentUser(); 294 | return user.id; 295 | }, 296 | }; 297 | 298 | const wrapper = createWrapper(impl, Provider); 299 | 300 | const { result } = renderHook(() => useUserId(), { wrapper }); 301 | 302 | expect(result.current).toEqual("12345"); 303 | }); 304 | 305 | describe("strict mode", () => { 306 | test("can swap out implementation when strict mode is false", async () => { 307 | type IFace = { 308 | useAddThree(a: number, b: number, c: number): number; 309 | }; 310 | 311 | const [hooks, Provider] = createFacade({ strict: false }); 312 | 313 | const implA: IFace = { 314 | useAddThree(a, b, c) { 315 | return React.useMemo(() => a + b + c, [a, b, c, "add"]); 316 | }, 317 | }; 318 | 319 | const implB: IFace = { 320 | useAddThree(a, b, c) { 321 | return React.useMemo(() => a * b * c, [a, b, c, "multiply"]); 322 | }, 323 | }; 324 | 325 | const Component = () => { 326 | const amount = hooks.useAddThree(1, 3, 5); 327 | 328 | return ( 329 | <> 330 |
{amount}
331 | 332 | ); 333 | }; 334 | 335 | const { getByTestId, rerender } = render( 336 | 337 | 338 | 339 | ); 340 | 341 | expect(getByTestId("amount").textContent).toEqual("9"); 342 | 343 | rerender( 344 | 345 | 346 | 347 | ); 348 | 349 | expect(getByTestId("amount").textContent).toEqual("15"); 350 | }); 351 | 352 | test("can't swap out implementation when strict mode is true", async () => { 353 | type IFace = { 354 | useAddThree(a: number, b: number, c: number): number; 355 | }; 356 | 357 | const implA: IFace = { 358 | useAddThree(a, b, c) { 359 | return React.useMemo(() => a + b + c, [a, b, c, "add"]); 360 | }, 361 | }; 362 | 363 | const implB: IFace = { 364 | useAddThree(a, b, c) { 365 | return React.useMemo(() => a * b * c, [a, b, c, "multiply"]); 366 | }, 367 | }; 368 | 369 | const [hooks, Provider] = createFacade({ strict: true }); 370 | 371 | const Component = () => { 372 | hooks.useAddThree(1, 3, 5); 373 | return null; 374 | }; 375 | 376 | const { rerender } = render( 377 | 378 | 379 | 380 | ); 381 | 382 | expect(() => { 383 | rerender( 384 | 385 | 386 | 387 | ); 388 | }).toThrowError( 389 | new Error( 390 | 'ImplementationProviderContext(Facade) unexpectedly received a new implementation. This is not allowed in strict mode. To disable this error use the option "strict: false".' 391 | ) 392 | ); 393 | }); 394 | }); 395 | -------------------------------------------------------------------------------- /src/create-facade.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import invariant from "invariant"; 3 | 4 | type AnyFunc = Function | ((...args: any[]) => any); 5 | 6 | type BasicFacadeInterface = { [hookName: string]: Function | BasicFacadeInterface }; 7 | 8 | /** 9 | * This type is used to filter out non-functions and objects from the interface. 10 | */ 11 | // prettier-ignore 12 | type AllObjectAndFunctionKeys = { 13 | [K in keyof T]: 14 | T[K] extends AnyFunc ? K : 15 | T[K] extends object ? K : 16 | never 17 | }[keyof T]; 18 | 19 | /** 20 | * This type recurses down through the interface and filters out non-functions and objects. 21 | * `AllObjectAndFunctionKeys` is doing the heavy lifting here because the keys are defacto 22 | * removed from the interface. 23 | */ 24 | // prettier-ignore 25 | type FilterNonFuncs = { 26 | [P in AllObjectAndFunctionKeys]: 27 | T[P] extends AnyFunc ? T[P] : 28 | T[P] extends object ? Readonly> : 29 | never; 30 | }; 31 | 32 | type Wrapper = React.JSXElementConstructor<{ children: React.ReactElement }>; 33 | 34 | export type ImplementationProvider = React.ComponentType> & { 35 | __UNSAFE_Test_Partial: React.ComponentType }>>; 36 | }; 37 | 38 | type Options = { 39 | /** 40 | * The displayName for debugging with React Devtools 41 | * @default Facade 42 | */ 43 | displayName: string; 44 | /** 45 | * When `true` does not allow the implementation to change between renders. Improves performance. 46 | * @default true 47 | */ 48 | strict: boolean; 49 | /** 50 | * A wrapper component that can be used to wrap the ImplementationProvider. 51 | * @default ({ children }) => children 52 | */ 53 | wrapper: Wrapper; 54 | }; 55 | 56 | const nullWrapper: Wrapper = ({ children }) => children; 57 | 58 | /** 59 | * This function interface is present so that when a "BasicFacadeInterface" is provided, 60 | * the resulting hook object will be typed as `Readonly` which is much easier to read 61 | * than FilterNonFuncs. That is, if you provide an interface without keys that need to be 62 | * filtered out, the resulting hook object will be typed as the much more readable `Readonly`. 63 | */ 64 | export function createFacade( 65 | options?: Partial 66 | ): [Readonly, ImplementationProvider]; 67 | 68 | /** 69 | * When an interface is provided for `T`, for example `interface A { ... }`, 70 | * and/or _any_ of the keys in `T` are not functions or objects, we fallback to this 71 | * which uses `FilterNonFuncs` to filter out non-hooks. It's a little harder to read than 72 | * the above, but without it we could not support interfaces. 73 | */ 74 | export function createFacade( 75 | options?: Partial 76 | ): [Readonly>, ImplementationProvider>]; 77 | 78 | /** 79 | * If we don't provide a `T`, then fallback to an empty interface. 80 | * You might be missing the point, if you fell into this function signature. 81 | */ 82 | export function createFacade(options: Partial = {}): [Readonly<{}>, ImplementationProvider<{}>] { 83 | type T = BasicFacadeInterface; 84 | 85 | const displayName = options.displayName ?? "Facade"; 86 | const strict = options.strict ?? true; 87 | const Wrapper = options.wrapper ?? nullWrapper; 88 | const providerNotFound = Symbol(); 89 | 90 | const Context = React.createContext(providerNotFound); 91 | Context.displayName = `ImplementationProviderContext(${displayName})`; 92 | 93 | const createRecursiveProxy = (keyPath: string[]) => { 94 | const keyCache = {} as { [K in keyof U]: U[K] }; 95 | 96 | /** 97 | * Use a function as the `target` so that the proxy object is callable. 98 | */ 99 | return new Proxy(function () {}, { 100 | apply(_target: any, _thisArg: any, args: any[]) { 101 | const concrete = React.useContext(Context); 102 | 103 | React.useDebugValue(keyPath); 104 | 105 | invariant( 106 | concrete !== providerNotFound, 107 | `Component using "${keyPath.join(".")}" must be wrapped in provider ${Context.displayName}` 108 | ); 109 | 110 | let target: any = concrete; 111 | let thisArg: any = undefined; 112 | 113 | const currentPath = []; 114 | 115 | for (const key of keyPath) { 116 | currentPath.push(key); 117 | thisArg = target; 118 | target = target[key]; 119 | 120 | invariant( 121 | target !== undefined, 122 | `${Context.displayName} does not provide a hook named "${currentPath.join(".")}"` 123 | ); 124 | } 125 | 126 | invariant( 127 | typeof target === "function", 128 | `${Context.displayName} provides a value "${currentPath.join(".")}" but it is not a function.` 129 | ); 130 | 131 | return target.apply(thisArg, args); 132 | }, 133 | get(_target: any, key0: string): any { 134 | const key = key0 as keyof U & string; 135 | 136 | if (key in keyCache) { 137 | return keyCache[key]; 138 | } 139 | 140 | const next = createRecursiveProxy([...keyPath, key]); 141 | 142 | keyCache[key] = next; 143 | 144 | return next; 145 | }, 146 | has() { 147 | return false; 148 | }, 149 | ownKeys() { 150 | return []; 151 | }, 152 | getOwnPropertyDescriptor() { 153 | return undefined; 154 | }, 155 | getPrototypeOf() { 156 | return null; 157 | }, 158 | preventExtensions() { 159 | return true; 160 | }, 161 | isExtensible() { 162 | return false; 163 | }, 164 | set() { 165 | return false; 166 | }, 167 | deleteProperty() { 168 | return false; 169 | }, 170 | }); 171 | }; 172 | 173 | const ImplementationProvider: ImplementationProvider = (props) => { 174 | const parent = React.useContext(Context); 175 | 176 | invariant( 177 | parent === providerNotFound, 178 | `${ImplementationProvider.displayName} should not be rendered inside of another ${ImplementationProvider.displayName}.` 179 | ); 180 | 181 | const implementationRef = React.useRef(props.implementation); 182 | 183 | invariant( 184 | !strict || implementationRef.current === props.implementation, 185 | `${Context.displayName} unexpectedly received a new implementation. This is not allowed in strict mode. To disable this error use the option "strict: false".` 186 | ); 187 | 188 | return ( 189 | 190 | {props.children} 191 | 192 | ); 193 | }; 194 | ImplementationProvider.displayName = `ImplementationProvider(${displayName})`; 195 | 196 | /** 197 | * Used to create a partial context to reduce setup tedium in test scenarios. 198 | */ 199 | ImplementationProvider.__UNSAFE_Test_Partial = (props) => ( 200 | {props.children} 201 | ); 202 | ImplementationProvider.__UNSAFE_Test_Partial.displayName = `ImplementationProvider.__UNSAFE_Test_Partial(${displayName})`; 203 | 204 | return [createRecursiveProxy([]), ImplementationProvider]; 205 | } 206 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createFacade } from "./create-facade"; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "es2015", 5 | "lib": ["dom", "es2017"], 6 | "jsx": "react", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "outDir": "build", 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["src/*.ts", "src/*.tsx"], 16 | "exclude": ["src/*.test.ts", "src/*.test.tsx"] 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "happy-dom", 6 | silent: true, 7 | watch: false, 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------