├── .eslintrc ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE.md ├── README.md ├── default.project.json ├── package.json ├── pnpm-lock.yaml ├── rokit.toml ├── src ├── index.ts ├── init.spec.ts ├── react-env.d.ts ├── use-async-callback │ ├── README.md │ ├── index.ts │ ├── use-async-callback.spec.ts │ └── use-async-callback.ts ├── use-async-effect │ ├── README.md │ ├── index.ts │ ├── use-async-effect.spec.ts │ └── use-async-effect.ts ├── use-async │ ├── README.md │ ├── index.ts │ ├── use-async.spec.ts │ └── use-async.ts ├── use-binding-listener │ ├── README.md │ ├── index.ts │ ├── use-binding-listener.spec.ts │ └── use-binding-listener.ts ├── use-binding-state │ ├── README.md │ ├── index.ts │ ├── use-binding-state.spec.ts │ └── use-binding-state.ts ├── use-camera │ ├── README.md │ ├── index.ts │ ├── use-camera.spec.ts │ └── use-camera.ts ├── use-composed-ref │ ├── README.md │ ├── index.ts │ ├── use-composed-ref.spec.ts │ └── use-composed-ref.ts ├── use-debounce-callback │ ├── README.md │ ├── index.ts │ ├── use-debounce-callback.spec.ts │ └── use-debounce-callback.ts ├── use-debounce-effect │ ├── README.md │ ├── index.ts │ ├── use-debounce-effect.spec.ts │ └── use-debounce-effect.ts ├── use-debounce-state │ ├── README.md │ ├── index.ts │ ├── use-debounce-state.spec.ts │ └── use-debounce-state.ts ├── use-defer-callback │ ├── README.md │ ├── index.ts │ ├── use-defer-callback.spec.ts │ └── use-defer-callback.ts ├── use-defer-effect │ ├── README.md │ ├── index.ts │ ├── use-defer-effect.spec.ts │ └── use-defer-effect.ts ├── use-defer-state │ ├── README.md │ ├── index.ts │ ├── use-defer-state.spec.ts │ └── use-defer-state.ts ├── use-event-listener │ ├── README.md │ ├── index.ts │ ├── use-event-listener.spec.ts │ └── use-event-listener.ts ├── use-interval │ ├── README.md │ ├── index.ts │ ├── use-interval.spec.ts │ └── use-interval.ts ├── use-key-press │ ├── README.md │ ├── index.ts │ ├── use-key-press.spec.ts │ └── use-key-press.ts ├── use-latest-callback │ ├── README.md │ ├── index.ts │ ├── use-latest-callback.spec.ts │ └── use-latest-callback.ts ├── use-latest │ ├── README.md │ ├── index.ts │ ├── use-latest.spec.ts │ └── use-latest.ts ├── use-lifetime │ ├── README.md │ ├── index.ts │ ├── use-lifetime.spec.ts │ └── use-lifetime.ts ├── use-motion │ ├── README.md │ ├── index.ts │ ├── use-motion.spec.ts │ └── use-motion.ts ├── use-mount-effect │ ├── README.md │ ├── index.ts │ ├── use-mount-effect.spec.ts │ └── use-mount-effect.ts ├── use-mouse │ ├── README.md │ ├── index.ts │ ├── use-mouse.spec.ts │ └── use-mouse.ts ├── use-previous │ ├── README.md │ ├── index.ts │ ├── use-previous.spec.ts │ └── use-previous.ts ├── use-spring │ ├── README.md │ ├── index.ts │ ├── use-spring.spec.ts │ └── use-spring.ts ├── use-tagged │ ├── README.md │ ├── index.ts │ ├── use-tagged.spec.ts │ └── use-tagged.ts ├── use-throttle-callback │ ├── README.md │ ├── index.ts │ ├── use-throttle-callback.spec.ts │ └── use-throttle-callback.ts ├── use-throttle-effect │ ├── README.md │ ├── index.ts │ ├── use-throttle-effect.spec.ts │ └── use-throttle-effect.ts ├── use-throttle-state │ ├── README.md │ ├── index.ts │ ├── use-throttle-state.spec.ts │ └── use-throttle-state.ts ├── use-timeout │ ├── README.md │ ├── index.ts │ ├── use-timeout.spec.ts │ └── use-timeout.ts ├── use-timer │ ├── README.md │ ├── index.ts │ ├── use-timer.spec.ts │ └── use-timer.ts ├── use-unmount-effect │ ├── README.md │ ├── index.ts │ ├── use-unmount-effect.spec.ts │ └── use-unmount-effect.ts ├── use-update-effect │ ├── README.md │ ├── index.ts │ ├── use-update-effect.spec.ts │ └── use-update-effect.ts ├── use-update │ ├── README.md │ ├── index.ts │ ├── use-update.spec.ts │ └── use-update.ts ├── use-viewport │ ├── README.md │ ├── index.ts │ ├── use-viewport.spec.ts │ └── use-viewport.ts └── utils │ ├── binding.ts │ ├── hoarcekat.tsx │ ├── math.ts │ ├── shallow-equal.ts │ └── testez.tsx ├── test.project.json ├── testez-companion.toml └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "jsx": true, 5 | "useJSXTextNode": true, 6 | "ecmaVersion": 2018, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "ignorePatterns": ["/out"], 11 | "plugins": ["@typescript-eslint", "roblox-ts", "prettier"], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:roblox-ts/recommended", 16 | "plugin:prettier/recommended" 17 | ], 18 | "rules": { 19 | "prettier/prettier": "warn", 20 | "@typescript-eslint/no-explicit-any": "off", 21 | "@typescript-eslint/no-unused-vars": "warn" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | with: 20 | run_install: true 21 | - uses: CompeyDev/setup-rokit@v0.1.2 22 | 23 | - name: Run ESLint 24 | run: pnpm eslint src 25 | 26 | - name: Build & Compile 27 | run: pnpm build --verbose 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /out 3 | /include 4 | *.tsbuildinfo 5 | .DS_Store 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Littensy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🌺 [pretty-react-hooks](https://npmjs.com/package/@rbxts/pretty-react-hooks) 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/pretty-react-hooks/ci.yml?branch=master&style=for-the-badge&logo=github) 4 | [![npm version](https://img.shields.io/npm/v/@rbxts/pretty-react-hooks.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/pretty-react-hooks) 5 | [![npm downloads](https://img.shields.io/npm/dt/@rbxts/pretty-react-hooks.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/pretty-react-hooks) 6 | [![GitHub license](https://img.shields.io/github/license/littensy/pretty-react-hooks?style=for-the-badge)](LICENSE.md) 7 | 8 | An opinionated collection of useful hooks and utilites for [React](https://github.com/littensy/rbxts-react) in [roblox-ts](https://roblox-ts.com). 9 | 10 | If you find a bug or have a feature request, please [open an issue](https://github.com/littensy/pretty-react-hooks/issues/new/). 11 | 12 |   13 | 14 | ## ⭐ Featured 15 | 16 | Check out some featured hooks: 17 | 18 | - [🦾 `useMotion`](src/use-motion/) - Creates a memoized Motion object set to the given initial value. Returns a binding that updates with the Motion, along with the Motion object. 19 | - [⏱️ `useAsync`](src/use-async/) - A hook that runs an async function and returns the result and status 20 | - [⚙️ `useTagged`](src/use-tagged/) - Tracks and returns a list of all instances with the given tag 21 | 22 | This package also exports some useful utilities: 23 | 24 | - [📕 `hoarcekat`](src/utils/hoarcekat.tsx) - Create a Hoarcekat story 25 | - [📦 `binding utils`](src/utils/binding.ts) - Work with values that may or may not be bindings 26 | 27 | Or, see the [full list of hooks](src/). 28 | 29 |   30 | 31 | ## 📦 Installation 32 | 33 | This package is available for Roblox TypeScript projects on [NPM](https://www.npmjs.com/package/@rbxts/pretty-react-hooks). 34 | 35 | ```sh 36 | npm install @rbxts/pretty-react-hooks 37 | yarn add @rbxts/pretty-react-hooks 38 | pnpm add @rbxts/pretty-react-hooks 39 | ``` 40 | 41 |   42 | 43 | ## 🌻 Contributing 44 | 45 | Contributions are welcome! Note that if you make a change to a hook, you should also check the tests and documentation. 46 | 47 | To get started, clone the repository and run `pnpm install`. Then, you can run the following commands: 48 | 49 | - `pnpm dev` - Enable watch mode with support for TestEZ Companion 50 | - `pnpm build` - Compile the package's `out` directory 51 | 52 | You will likely need the following extensions: 53 | 54 | - [Rojo VSCode extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo) 55 | - [TestEZ Companion](https://marketplace.visualstudio.com/items?itemName=tacheometrist.testez-companion) 56 | 57 |   58 | 59 | ## 📝 License 60 | 61 | pretty-react-hooks is licensed under the [MIT License](LICENSE.md). 62 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pretty-react-hooks", 3 | "tree": { 4 | "$path": "out/" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rbxts/pretty-react-hooks", 3 | "version": "0.6.4", 4 | "description": "Useful hooks for @rbxts/react", 5 | "main": "out/init.lua", 6 | "scripts": { 7 | "build": "rbxtsc", 8 | "dev": "rbxtsc -w --type game --rojo test.project.json", 9 | "prepublishOnly": "pnpm build" 10 | }, 11 | "keywords": [ 12 | "roact", 13 | "react", 14 | "roblox-ts" 15 | ], 16 | "author": "littensy", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/littensy/pretty-react-hooks.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/littensy/pretty-react-hooks/issues" 24 | }, 25 | "types": "out/index.d.ts", 26 | "files": [ 27 | "out", 28 | "!**/*.tsbuildinfo", 29 | "!**/*.spec.lua", 30 | "!**/*.spec.d.ts" 31 | ], 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "devDependencies": { 36 | "@rbxts/compiler-types": "3.0.0-types.0", 37 | "@rbxts/react": "17.3.0-alpha.1", 38 | "@rbxts/react-roblox": "17.3.0-alpha.1", 39 | "@rbxts/testez": "0.4.2-ts.0", 40 | "@rbxts/types": "^1.0.854", 41 | "@typescript-eslint/eslint-plugin": "^8.32.1", 42 | "@typescript-eslint/parser": "^8.32.1", 43 | "eslint": "^8.57.1", 44 | "eslint-config-prettier": "^8.10.0", 45 | "eslint-plugin-prettier": "^4.2.1", 46 | "eslint-plugin-roblox-ts": "^0.0.35", 47 | "prettier": "^2.8.8", 48 | "roblox-ts": "3.0.0", 49 | "typescript": "^5.8.3" 50 | }, 51 | "dependencies": { 52 | "@rbxts/ripple": "^0.9.3", 53 | "@rbxts/services": "^1.5.5", 54 | "@rbxts/set-timeout": "^1.1.2" 55 | }, 56 | "peerDependencies": { 57 | "@rbxts/react": "*", 58 | "@rbxts/react-roblox": "*" 59 | }, 60 | "packageManager": "pnpm@10.11.0" 61 | } 62 | -------------------------------------------------------------------------------- /rokit.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Rokit, a toolchain manager for Roblox projects. 2 | # For more information, see https://github.com/rojo-rbx/rokit 3 | 4 | # New tools can be added by running `rokit add ` in a terminal. 5 | 6 | [tools] 7 | rojo = "rojo-rbx/rojo@7.5.1" 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils/binding"; 2 | export * from "./utils/hoarcekat"; 3 | export * from "./utils/math"; 4 | export * from "./utils/testez"; 5 | 6 | export * from "./use-async"; 7 | export * from "./use-async-callback"; 8 | export * from "./use-async-effect"; 9 | export * from "./use-binding-listener"; 10 | export * from "./use-binding-state"; 11 | export * from "./use-camera"; 12 | export * from "./use-composed-ref"; 13 | export * from "./use-debounce-callback"; 14 | export * from "./use-debounce-effect"; 15 | export * from "./use-debounce-state"; 16 | export * from "./use-defer-callback"; 17 | export * from "./use-defer-effect"; 18 | export * from "./use-defer-state"; 19 | export * from "./use-event-listener"; 20 | export * from "./use-interval"; 21 | export * from "./use-key-press"; 22 | export * from "./use-latest"; 23 | export * from "./use-latest-callback"; 24 | export * from "./use-lifetime"; 25 | export * from "./use-motion"; 26 | export * from "./use-mount-effect"; 27 | export * from "./use-mouse"; 28 | export * from "./use-previous"; 29 | export * from "./use-spring"; 30 | export * from "./use-tagged"; 31 | export * from "./use-throttle-callback"; 32 | export * from "./use-throttle-effect"; 33 | export * from "./use-throttle-state"; 34 | export * from "./use-timeout"; 35 | export * from "./use-timer"; 36 | export * from "./use-unmount-effect"; 37 | export * from "./use-update"; 38 | export * from "./use-update-effect"; 39 | export * from "./use-viewport"; 40 | -------------------------------------------------------------------------------- /src/init.spec.ts: -------------------------------------------------------------------------------- 1 | _G.__ROACT_17_MOCK_SCHEDULER__ = true; 2 | 3 | export = () => { 4 | afterAll(() => { 5 | _G.__ROACT_17_MOCK_SCHEDULER__ = undefined; 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/react-env.d.ts: -------------------------------------------------------------------------------- 1 | interface _G { 2 | __DEV__?: boolean; 3 | __ROACT_17_MOCK_SCHEDULER__?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/use-async-callback/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useAsyncCallback` 2 | 3 | ```ts 4 | function useAsyncCallback( 5 | callback: AsyncCallback, 6 | ): LuaTuple<[AsyncState, AsyncCallback]>; 7 | ``` 8 | 9 | A hook that wraps an async function and returns the current state and an executor. 10 | 11 | Calling the executor will cancel any pending promises and start a new one. 12 | 13 | If you want the callback to run on mount or with dependencies, see [`useAsync`](../use-async). 14 | 15 | > **Warning:** 16 | > Cancelling a promise that yielded using `await` does not prevent the thread from resuming. 17 | > Avoid pairing `await` with functions that update state, as it might resume after unmount: 18 | > 19 | > ```ts 20 | > useAsyncCallback(async () => { 21 | > await Promise.delay(5); 22 | > setState("Hello World!"); // unsafe 23 | > }); 24 | > ``` 25 | 26 | ### 📕 Parameters 27 | 28 | - `callback` - The async function to call. 29 | 30 | ### 📗 Returns 31 | 32 | - The current state of the async function. 33 | - `status` - The status of the last promise. 34 | - `value` - The value if the promise resolved. 35 | - `message` - The error message if the promise rejected. 36 | - A function that calls the async function. 37 | 38 | ### 📘 Example 39 | 40 | ```tsx 41 | function GetBaseplate() { 42 | const [state, getBaseplate] = useAsyncCallback(async () => { 43 | return Workspace.WaitForChild("Baseplate"); 44 | }); 45 | 46 | useEffect(() => { 47 | print("Baseplate", state.status, state.value); 48 | }, [state]); 49 | 50 | return ( 51 | getBaseplate(), 55 | }} 56 | /> 57 | ); 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /src/use-async-callback/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-async-callback"; 2 | -------------------------------------------------------------------------------- /src/use-async-callback/use-async-callback.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useAsyncCallback } from "./use-async-callback"; 5 | 6 | export = () => { 7 | it("should return the current state and a callback", () => { 8 | const { result } = renderHook(() => { 9 | const [state, callback] = useAsyncCallback(() => Promise.resolve("foo")); 10 | return { state, callback }; 11 | }); 12 | 13 | expect(result.current.state.status).to.be.equal(Promise.Status.Started); 14 | expect(result.current.state.value).to.never.be.ok(); 15 | expect(result.current.state.message).to.never.be.ok(); 16 | expect(result.current.callback).to.be.a("function"); 17 | }); 18 | 19 | it("should update the state when the promise resolves", () => { 20 | const { result } = renderHook(() => { 21 | const [state, callback] = useAsyncCallback(() => Promise.resolve("foo")); 22 | return { state, callback }; 23 | }); 24 | 25 | result.current.callback(); 26 | expect(result.current.state.status).to.be.equal(Promise.Status.Resolved); 27 | expect(result.current.state.value).to.be.equal("foo"); 28 | expect(result.current.state.message).to.never.be.ok(); 29 | }); 30 | 31 | it("should update the state when the promise rejects", () => { 32 | const { result } = renderHook(() => { 33 | const [state, callback] = useAsyncCallback(() => Promise.reject("foo")); 34 | return { state, callback }; 35 | }); 36 | 37 | result.current.callback(); 38 | expect(result.current.state.status).to.be.equal(Promise.Status.Rejected); 39 | expect(result.current.state.value).to.never.be.ok(); 40 | expect(result.current.state.message).to.be.equal("foo"); 41 | }); 42 | 43 | it("should cancel the previous promise", () => { 44 | let completions = 0; 45 | const { result } = renderHook(() => { 46 | const [state, callback] = useAsyncCallback(() => Promise.delay(0).then(() => ++completions)); 47 | return { state, callback }; 48 | }); 49 | 50 | result.current.callback(); 51 | result.current.callback(); 52 | result.current.callback(); 53 | 54 | expect(completions).to.be.equal(0); 55 | expect(result.current.state.status).to.be.equal(Promise.Status.Started); 56 | expect(result.current.state.value).to.never.be.ok(); 57 | expect(result.current.state.message).to.never.be.ok(); 58 | 59 | task.wait(0.04); 60 | expect(completions).to.be.equal(1); 61 | expect(result.current.state.status).to.be.equal(Promise.Status.Resolved); 62 | expect(result.current.state.value).to.be.equal(1); 63 | expect(result.current.state.message).to.never.be.ok(); 64 | }); 65 | 66 | it("should cancel when unmounting", () => { 67 | let completions = 0; 68 | const { result, unmount } = renderHook(() => { 69 | const [state, callback] = useAsyncCallback(() => Promise.delay(0).then(() => ++completions)); 70 | return { state, callback }; 71 | }); 72 | 73 | result.current.callback(); 74 | unmount(); 75 | task.wait(0.04); 76 | expect(completions).to.be.equal(0); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /src/use-async-callback/use-async-callback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "@rbxts/react"; 2 | import { useUnmountEffect } from "../use-unmount-effect"; 3 | 4 | export type AsyncState = 5 | | { 6 | status: PromiseConstructor["Status"]["Started"]; 7 | message?: undefined; 8 | value?: undefined; 9 | } 10 | | { 11 | status: PromiseConstructor["Status"]["Resolved"]; 12 | message?: undefined; 13 | value: T; 14 | } 15 | | { 16 | status: PromiseConstructor["Status"]["Cancelled"] | PromiseConstructor["Status"]["Rejected"]; 17 | message: unknown; 18 | value?: undefined; 19 | }; 20 | 21 | type AnyAsyncState = { 22 | status: Promise.Status; 23 | message?: unknown; 24 | value?: T; 25 | }; 26 | 27 | export type AsyncCallback = (...args: U) => Promise; 28 | 29 | /** 30 | * Returns a tuple containing the current state of the promise and a callback 31 | * to start a new promise. Calling it will cancel any previous promise. 32 | * @param callback The async callback. 33 | * @returns The state and a new callback. 34 | */ 35 | export function useAsyncCallback( 36 | callback: AsyncCallback, 37 | ): LuaTuple<[AsyncState, AsyncCallback]> { 38 | const currentPromise = useRef>(); 39 | 40 | const [state, setState] = useState>({ 41 | status: Promise.Status.Started, 42 | }); 43 | 44 | const execute = useCallback( 45 | (...args: U) => { 46 | currentPromise.current?.cancel(); 47 | 48 | if (state.status !== Promise.Status.Started) { 49 | setState({ status: Promise.Status.Started }); 50 | } 51 | 52 | const promise = callback(...args); 53 | 54 | promise.then( 55 | (value) => setState({ status: promise.getStatus(), value }), 56 | (message: unknown) => setState({ status: promise.getStatus(), message }), 57 | ); 58 | 59 | return (currentPromise.current = promise); 60 | }, 61 | [callback], 62 | ); 63 | 64 | useUnmountEffect(() => { 65 | currentPromise.current?.cancel(); 66 | }); 67 | 68 | return $tuple(state as AsyncState, execute); 69 | } 70 | -------------------------------------------------------------------------------- /src/use-async-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useAsyncEffect` 2 | 3 | ```ts 4 | function useAsyncEffect(effect: () => Promise, deps?: unknown[]): void; 5 | ``` 6 | 7 | Runs an async effect and cancels the promise when unmounting the effect. 8 | 9 | If you want to use the result or status of the callback, see [`useAsync`](../use-async). 10 | 11 | > **Warning:** 12 | > Cancelling a promise that yielded using `await` does not prevent the thread from resuming. 13 | > Avoid pairing `await` with functions that update state, as it might resume after unmount: 14 | > 15 | > ```ts 16 | > useAsyncEffect(async () => { 17 | > await Promise.delay(5); 18 | > setState("Hello World!"); // unsafe 19 | > }, []); 20 | > ``` 21 | 22 | ### 📕 Parameters 23 | 24 | - `effect` - The async effect to run. 25 | - `deps` - The dependencies to watch for changes. 26 | 27 | ### 📗 Returns 28 | 29 | - `void` 30 | 31 | ### 📘 Example 32 | 33 | ```tsx 34 | function Countdown() { 35 | const [counter, setCounter] = useState(0); 36 | 37 | useAsyncEffect(async () => { 38 | return setCountdown((countdown) => { 39 | setCounter(countdown); 40 | }, 10); 41 | }, []); 42 | 43 | return ; 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /src/use-async-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-async-effect"; 2 | -------------------------------------------------------------------------------- /src/use-async-effect/use-async-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useAsyncEffect } from "./use-async-effect"; 5 | 6 | export = () => { 7 | it("should run the effect", () => { 8 | let calls = 0; 9 | renderHook(() => useAsyncEffect(async () => calls++, [])); 10 | expect(calls).to.equal(1); 11 | }); 12 | 13 | it("should run the effect when the dependencies change", () => { 14 | let calls = 0; 15 | const { rerender } = renderHook((deps: unknown[]) => useAsyncEffect(async () => calls++, deps), { 16 | initialProps: [0], 17 | }); 18 | 19 | expect(calls).to.equal(1); 20 | rerender([1]); 21 | expect(calls).to.equal(2); 22 | }); 23 | 24 | it("should cancel the effect when unmounting", () => { 25 | let calls = 0; 26 | const { unmount } = renderHook(() => { 27 | useAsyncEffect(async () => { 28 | calls++; 29 | return Promise.delay(0).then(() => { 30 | calls++; 31 | }); 32 | }, []); 33 | }); 34 | 35 | expect(calls).to.equal(1); 36 | unmount(); 37 | expect(calls).to.equal(1); 38 | }); 39 | 40 | it("should allow promises to complete", () => { 41 | let calls = 0; 42 | renderHook(() => { 43 | useAsyncEffect(async () => { 44 | calls++; 45 | return Promise.delay(0).then(() => { 46 | calls++; 47 | }); 48 | }, []); 49 | }); 50 | 51 | expect(calls).to.equal(1); 52 | task.wait(0.04); 53 | expect(calls).to.equal(2); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /src/use-async-effect/use-async-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | 3 | /** 4 | * Runs an async effect and cancels the promise when unmounting the effect. 5 | * Note that effects paused by `await` still run while cancelled, so prefer 6 | * to use promise chaining instead. 7 | * @param effect The async effect to run. 8 | * @param deps The dependencies to run the effect on. 9 | */ 10 | export function useAsyncEffect(effect: () => Promise, deps?: unknown[]) { 11 | useEffect(() => { 12 | const promise = effect(); 13 | 14 | return () => { 15 | promise.cancel(); 16 | }; 17 | }, deps); 18 | } 19 | -------------------------------------------------------------------------------- /src/use-async/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useAsync` 2 | 3 | ```ts 4 | function useAsync( 5 | callback: () => Promise, 6 | deps: unknown[] = [], 7 | ): [result?: T, status?: Promise.Status, message?: unknown]; 8 | ``` 9 | 10 | A hook that runs an async function and returns the result and status. Similar to `useAsyncCallback`, but the callback is run on mount and whenever the dependencies change. 11 | 12 | Changing the dependencies will cancel any pending promises and start a new one. 13 | 14 | > **Warning:** 15 | > Cancelling a promise that yielded using `await` does not prevent the thread from resuming. 16 | > Avoid pairing `await` with functions that update state, as it might resume after unmount: 17 | > 18 | > ```ts 19 | > useAsync(async () => { 20 | > await Promise.delay(5); 21 | > setState("Hello World!"); // unsafe 22 | > }); 23 | > ``` 24 | 25 | ### 📕 Parameters 26 | 27 | - `callback` - The async function to run. 28 | - `deps` - The dependencies to watch for changes. Defaults to an empty array. 29 | 30 | ### 📗 Returns 31 | 32 | - The result if the promise resolved. 33 | - The status of the promise. 34 | - The error message if the promise rejected or cancelled. 35 | 36 | ### 📘 Example 37 | 38 | ```tsx 39 | function BaseplatePortal(props: React.PropsWithChildren) { 40 | const [baseplate] = useAsync(async () => { 41 | return Workspace.WaitForChild("Baseplate"); 42 | }); 43 | 44 | if (!baseplate) { 45 | return undefined!; 46 | } 47 | 48 | return createPortal(props.children, baseplate); 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /src/use-async/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-async"; 2 | -------------------------------------------------------------------------------- /src/use-async/use-async.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useAsync } from "./use-async"; 5 | 6 | export = () => { 7 | it("should run the promise on mount", () => { 8 | const { result } = renderHook(() => { 9 | const [value, status, message] = useAsync(() => Promise.resolve("foo")); 10 | return { value, status, message }; 11 | }); 12 | expect(result.current.status).to.be.equal(Promise.Status.Resolved); 13 | expect(result.current.value).to.be.equal("foo"); 14 | expect(result.current.message).to.never.be.ok(); 15 | }); 16 | 17 | it("should run the promise when the dependencies change", () => { 18 | const { result, rerender } = renderHook( 19 | (deps: unknown[]) => { 20 | const [value, status, message] = useAsync(() => Promise.resolve(deps[0]), [deps]); 21 | return { value, status, message }; 22 | }, 23 | { initialProps: [0] }, 24 | ); 25 | 26 | expect(result.current.status).to.be.equal(Promise.Status.Resolved); 27 | expect(result.current.value).to.be.equal(0); 28 | expect(result.current.message).to.never.be.ok(); 29 | 30 | rerender([1]); 31 | expect(result.current.status).to.be.equal(Promise.Status.Resolved); 32 | expect(result.current.value).to.be.equal(1); 33 | expect(result.current.message).to.never.be.ok(); 34 | }); 35 | 36 | it("should cancel the previous promise", () => { 37 | let completions = 0; 38 | const { result, rerender } = renderHook( 39 | (deps: unknown[]) => { 40 | const [value, status, message] = useAsync(() => Promise.delay(0).then(() => ++completions), [deps]); 41 | return { value, status, message }; 42 | }, 43 | { initialProps: [0] }, 44 | ); 45 | 46 | rerender([1]); 47 | rerender([2]); 48 | rerender([3]); 49 | 50 | expect(completions).to.be.equal(0); 51 | expect(result.current.status).to.be.equal(Promise.Status.Started); 52 | expect(result.current.value).to.never.be.ok(); 53 | expect(result.current.message).to.never.be.ok(); 54 | 55 | task.wait(0.04); 56 | expect(completions).to.be.equal(1); 57 | expect(result.current.status).to.be.equal(Promise.Status.Resolved); 58 | expect(result.current.value).to.be.equal(1); 59 | expect(result.current.message).to.never.be.ok(); 60 | }); 61 | 62 | it("should update the state when the promise resolves", () => { 63 | const { result } = renderHook(() => { 64 | const [value, status, message] = useAsync(() => Promise.delay(0).then(() => "foo")); 65 | return { value, status, message }; 66 | }); 67 | 68 | expect(result.current.status).to.be.equal(Promise.Status.Started); 69 | expect(result.current.value).to.never.be.ok(); 70 | expect(result.current.message).to.never.be.ok(); 71 | 72 | task.wait(0.04); 73 | expect(result.current.status).to.be.equal(Promise.Status.Resolved); 74 | expect(result.current.value).to.be.equal("foo"); 75 | expect(result.current.message).to.never.be.ok(); 76 | }); 77 | 78 | it("should cancel the promise on unmount", () => { 79 | let completions = 0; 80 | const { unmount } = renderHook(() => { 81 | useAsync(() => Promise.delay(0).then(() => ++completions)); 82 | }); 83 | 84 | unmount(); 85 | task.wait(0.04); 86 | expect(completions).to.be.equal(0); 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /src/use-async/use-async.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { AsyncState, useAsyncCallback } from "../use-async-callback"; 3 | 4 | type AsyncStateTuple> = LuaTuple< 5 | [result: T["value"], status: T["status"], message: T["message"]] 6 | >; 7 | 8 | /** 9 | * Returns a tuple containing the result and status of a promise. When the 10 | * dependencies change, pending promises will be cancelled, and a new promise 11 | * will be started. 12 | * @param callback The async callback. 13 | * @param deps The dependencies to watch. Defaults to an empty array. 14 | * @returns The result and status of the promise. 15 | */ 16 | export function useAsync(callback: () => Promise, deps: unknown[] = []): AsyncStateTuple> { 17 | const [state, asyncCallback] = useAsyncCallback(callback); 18 | 19 | useEffect(() => { 20 | asyncCallback(); 21 | }, deps); 22 | 23 | return $tuple(state.value, state.status, state.message); 24 | } 25 | -------------------------------------------------------------------------------- /src/use-binding-listener/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useBindingListener` 2 | 3 | ```ts 4 | function useBindingListener(binding: T | Binding, listener: (value: T) => void): void; 5 | ``` 6 | 7 | Subscribes the given listener to binding updates. The listener will be called with the current value of the binding when the component is mounted, and then again whenever the binding updates. 8 | 9 | If not passed a valid binding, the listener will be called with the value passed to the hook. 10 | 11 | The `listener` parameter is memoized for you, and will only be called when the binding updates. 12 | 13 | ### 📕 Parameters 14 | 15 | - `binding` - The binding to subscribe to. 16 | - `listener` - The listener to call when the binding updates. 17 | 18 | ### 📗 Returns 19 | 20 | - `void` 21 | 22 | ### 📘 Example 23 | 24 | ```tsx 25 | interface Props { 26 | transparency: number | Binding; 27 | } 28 | 29 | function TransparentFrame({ transparency }: Props) { 30 | useBindingListener(transparency, (value) => { 31 | print("Binding updated to", value); 32 | }); 33 | 34 | return ; 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /src/use-binding-listener/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-binding-listener"; 2 | -------------------------------------------------------------------------------- /src/use-binding-listener/use-binding-listener.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createBinding } from "@rbxts/react"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useBindingListener } from "./use-binding-listener"; 6 | 7 | export = () => { 8 | it("should call listener on mount", () => { 9 | const [binding] = createBinding(0); 10 | let result: number | undefined; 11 | 12 | renderHook(({ listener }) => useBindingListener(binding, listener), { 13 | initialProps: { listener: (value: number) => (result = value) }, 14 | }); 15 | 16 | expect(result).to.equal(0); 17 | }); 18 | 19 | it("should call listener when the binding updates", () => { 20 | const [binding, setBinding] = createBinding(0); 21 | let result: number | undefined; 22 | 23 | const { rerender } = renderHook(({ listener }) => useBindingListener(binding, listener), { 24 | initialProps: { listener: (value: number) => (result = value) }, 25 | }); 26 | 27 | expect(result).to.equal(0); 28 | 29 | setBinding(1); 30 | rerender(); 31 | expect(result).to.equal(1); 32 | }); 33 | 34 | it("should not call listener after unrelated rerender", () => { 35 | const [binding] = createBinding(0); 36 | 37 | const { rerender } = renderHook(({ listener }) => useBindingListener(binding, listener), { 38 | initialProps: { listener: () => {} }, 39 | }); 40 | 41 | rerender({ 42 | listener: () => { 43 | throw "listener was called when the binding did not update"; 44 | }, 45 | }); 46 | 47 | rerender(); 48 | }); 49 | 50 | it("should not call listener if the listener changes", () => { 51 | const [binding] = createBinding(0); 52 | 53 | const { rerender } = renderHook(({ listener }) => useBindingListener(binding, listener), { 54 | initialProps: { listener: () => {} }, 55 | }); 56 | 57 | rerender({ 58 | listener: () => { 59 | throw "listener was called when the listener changed"; 60 | }, 61 | }); 62 | 63 | rerender(); 64 | }); 65 | 66 | it("should call listener if the passed binding changes", () => { 67 | const [bindingA] = createBinding(0); 68 | const [bindingB] = createBinding(1); 69 | let result: number | undefined; 70 | 71 | const { rerender } = renderHook( 72 | ({ binding }) => useBindingListener(binding, (value: number) => (result = value)), 73 | { initialProps: { binding: bindingA } }, 74 | ); 75 | 76 | expect(result).to.equal(0); 77 | 78 | rerender({ binding: bindingB }); 79 | expect(result).to.equal(1); 80 | }); 81 | 82 | it("should call listener if passed value changes", () => { 83 | let result: number | undefined; 84 | 85 | const { rerender } = renderHook(({ value }) => useBindingListener(value, (value: number) => (result = value)), { 86 | initialProps: { value: 0 }, 87 | }); 88 | 89 | expect(result).to.equal(0); 90 | 91 | rerender({ value: 1 }); 92 | expect(result).to.equal(1); 93 | }); 94 | }; 95 | -------------------------------------------------------------------------------- /src/use-binding-listener/use-binding-listener.ts: -------------------------------------------------------------------------------- 1 | import { Binding, useEffect, useMemo } from "@rbxts/react"; 2 | import { useLatestCallback } from "../use-latest-callback"; 3 | import { getBindingApi, isBinding } from "../utils/binding"; 4 | 5 | /** 6 | * Subscribes to a binding and calls the given listener when the binding 7 | * updates. If the value passed is not a binding, the listener will be called 8 | * with the value. 9 | * 10 | * The `listener` function is safe to not be memoized, as it will only be 11 | * called when the binding updates. 12 | * 13 | * @param binding The binding to subscribe to. 14 | * @param listener The function to call when the binding updates. 15 | */ 16 | export function useBindingListener(binding: T | Binding, listener: (value: T) => void) { 17 | const api = useMemo(() => { 18 | return isBinding(binding) ? getBindingApi(binding) : undefined; 19 | }, [binding]); 20 | 21 | const listenerCallback = useLatestCallback(listener); 22 | 23 | useEffect(() => { 24 | if (api) { 25 | listenerCallback(api.getValue()); 26 | return api.subscribe(listenerCallback); 27 | } else { 28 | listenerCallback(binding as T); 29 | } 30 | }, [binding]); 31 | } 32 | -------------------------------------------------------------------------------- /src/use-binding-state/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useBindingState` 2 | 3 | ```ts 4 | function useBindingState(binding: T | Binding): T; 5 | ``` 6 | 7 | Returns the value of the given binding. When the binding updates, the component will be re-rendered with the new value. 8 | 9 | If not passed a valid binding, the value passed to the hook will be returned. 10 | 11 | ### 📕 Parameters 12 | 13 | - `binding` - The binding to subscribe to. 14 | 15 | ### 📗 Returns 16 | 17 | - The value of the binding. 18 | 19 | ### 📘 Example 20 | 21 | ```tsx 22 | interface Props { 23 | visible: boolean | Binding; 24 | } 25 | 26 | function ToggleFrame({ visible }: Props) { 27 | const isVisible = useBindingState(visible); 28 | 29 | useEffect(() => { 30 | print("Visible changed to", isVisible); 31 | }, [isVisible]); 32 | 33 | return ; 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /src/use-binding-state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-binding-state"; 2 | -------------------------------------------------------------------------------- /src/use-binding-state/use-binding-state.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createBinding } from "@rbxts/react"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useBindingState } from "./use-binding-state"; 6 | 7 | export = () => { 8 | it("should return the current value", () => { 9 | const [binding] = createBinding(0); 10 | const { result } = renderHook(() => useBindingState(binding)); 11 | expect(result.current).to.equal(0); 12 | }); 13 | 14 | it("should update the value when the binding updates", () => { 15 | const [binding, setBinding] = createBinding(0); 16 | const { result } = renderHook(() => useBindingState(binding)); 17 | expect(result.current).to.equal(0); 18 | setBinding(1); 19 | expect(result.current).to.equal(1); 20 | }); 21 | 22 | it("should not update the value after unrelated rerender", () => { 23 | const [binding] = createBinding(0); 24 | const { result, rerender } = renderHook(() => useBindingState(binding)); 25 | expect(result.current).to.equal(0); 26 | rerender(); 27 | expect(result.current).to.equal(0); 28 | }); 29 | 30 | it("should update the value if the binding changes", () => { 31 | const [binding] = createBinding(0); 32 | const { result, rerender } = renderHook(({ binding }) => useBindingState(binding), { 33 | initialProps: { binding }, 34 | }); 35 | expect(result.current).to.equal(0); 36 | rerender({ binding: createBinding(1)[0] }); 37 | expect(result.current).to.equal(1); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/use-binding-state/use-binding-state.ts: -------------------------------------------------------------------------------- 1 | import { Binding, useState } from "@rbxts/react"; 2 | import { useBindingListener } from "../use-binding-listener"; 3 | import { getBindingValue } from "../utils/binding"; 4 | 5 | /** 6 | * Returns the value of a binding. If the binding updates, the component will 7 | * be re-rendered. Non-binding values will be returned as-is. 8 | * @param binding The binding to get the value of. 9 | * @returns The value of the binding. 10 | */ 11 | export function useBindingState(binding: T | Binding): T { 12 | const [value, setValue] = useState(() => getBindingValue(binding)); 13 | useBindingListener(binding, setValue); 14 | return value; 15 | } 16 | -------------------------------------------------------------------------------- /src/use-camera/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useCamera` 2 | 3 | ```tsx 4 | function useCamera(): Camera; 5 | ``` 6 | 7 | Returns the value of `Workspace.CurrentCamera`. If the current camera changes, it will trigger a re-render. 8 | 9 | ### 📕 Parameters 10 | 11 | ### 📗 Returns 12 | 13 | - A camera instance. 14 | 15 | ### 📘 Example 16 | 17 | ```tsx 18 | function CameraPortal() { 19 | const camera = useCamera(); 20 | 21 | return createPortal(, camera); 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /src/use-camera/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-camera"; 2 | -------------------------------------------------------------------------------- /src/use-camera/use-camera.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { RunService, Workspace } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useCamera } from "./use-camera"; 6 | 7 | export = () => { 8 | it("should return current camera", () => { 9 | const { result } = renderHook(() => useCamera()); 10 | expect(result.current).to.equal(Workspace.CurrentCamera); 11 | }); 12 | 13 | it("should update when current camera changes", () => { 14 | const { result, rerender } = renderHook(() => useCamera()); 15 | expect(result.current).to.equal(Workspace.CurrentCamera); 16 | Workspace.CurrentCamera?.Destroy(); // force camera change 17 | RunService.Heartbeat.Wait(); // task.wait() unreliable here 18 | rerender(); 19 | expect(result.current).to.equal(Workspace.CurrentCamera); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/use-camera/use-camera.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "@rbxts/react"; 2 | import { Workspace } from "@rbxts/services"; 3 | import { useEventListener } from "../use-event-listener"; 4 | 5 | /** 6 | * Returns the current camera. Updates when the current camera changes. 7 | * @returns A camera instance. 8 | */ 9 | export function useCamera() { 10 | const [camera, setCamera] = useState(Workspace.CurrentCamera!); 11 | 12 | useEventListener(Workspace.GetPropertyChangedSignal("CurrentCamera"), () => { 13 | if (Workspace.CurrentCamera) { 14 | setCamera(Workspace.CurrentCamera); 15 | } 16 | }); 17 | 18 | return camera; 19 | } 20 | -------------------------------------------------------------------------------- /src/use-composed-ref/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useComposedRef` 2 | 3 | ```ts 4 | function useComposedRef(...refs: (RefFunction | undefined)[]): RefFunction; 5 | ``` 6 | 7 | Combines multiple ref functions into a single ref function and memoizes the result. 8 | 9 | To prevent excess ref calls, the composed ref is only created once on mount. It will call the latest refs passed, though, so it is safe to pass in refs that might change. 10 | 11 | ### 📕 Parameters 12 | 13 | - `refs` - An array of ref functions. 14 | 15 | ### 📗 Returns 16 | 17 | - A ref function that calls all of the given ref functions. 18 | 19 | ### 📘 Example 20 | 21 | ```tsx 22 | interface Props { 23 | ref?: RefFunction; 24 | } 25 | 26 | function DraggableFrame({ ref }: Props) { 27 | const [frame, setFrame] = useState(); 28 | const composedRef = useComposedRef(ref, setFrame); 29 | 30 | useEffect(() => { 31 | const handle = makeDraggable(frame); 32 | 33 | return () => { 34 | handle.disconnect(); 35 | }; 36 | }, [frame]); 37 | 38 | return ; 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /src/use-composed-ref/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-composed-ref"; 2 | -------------------------------------------------------------------------------- /src/use-composed-ref/use-composed-ref.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { shallowEqual } from "../utils/shallow-equal"; 4 | import { renderHook } from "../utils/testez"; 5 | import { RefFunction, useComposedRef } from "./use-composed-ref"; 6 | 7 | export = () => { 8 | it("should call all refs passed in", () => { 9 | const results: string[] = []; 10 | 11 | const { rerender, result } = renderHook(() => { 12 | const ref = useComposedRef( 13 | (value = "") => (results[0] = value), 14 | (value = "") => (results[1] = value), 15 | (value = "") => (results[2] = value), 16 | ); 17 | 18 | return ref; 19 | }); 20 | 21 | expect(shallowEqual(results, [])).to.equal(true); 22 | 23 | rerender(); 24 | expect(shallowEqual(results, [])).to.equal(true); 25 | 26 | result.current("foo"); 27 | expect(shallowEqual(results, ["foo", "foo", "foo"])).to.equal(true); 28 | }); 29 | 30 | it("should skip refs that are undefined", () => { 31 | const results: (string | undefined)[] = []; 32 | 33 | const { rerender, result } = renderHook(() => { 34 | return useComposedRef( 35 | (value) => (results[0] = value), 36 | undefined, 37 | (value) => (results[1] = value), 38 | undefined, 39 | (value) => (results[2] = value), 40 | ); 41 | }); 42 | 43 | expect(shallowEqual(results, [])).to.equal(true); 44 | 45 | rerender(); 46 | expect(shallowEqual(results, [])).to.equal(true); 47 | 48 | result.current("foo"); 49 | expect(shallowEqual(results, ["foo", "foo", "foo"])).to.equal(true); 50 | }); 51 | 52 | it("should call the latest refs", () => { 53 | const calls = { a: 0, b: 0 }; 54 | 55 | const { rerender, result } = renderHook( 56 | (refs) => { 57 | return useComposedRef(...refs); 58 | }, 59 | { initialProps: [] as RefFunction[] }, 60 | ); 61 | 62 | rerender([(key = "a") => calls[key]++, (key = "a") => calls[key]++]); 63 | result.current("a"); 64 | expect(calls.a).to.equal(2); 65 | expect(calls.b).to.equal(0); 66 | 67 | rerender([(key = "b") => calls[key]++, (key = "b") => calls[key]++]); 68 | result.current("b"); 69 | expect(calls.a).to.equal(2); 70 | expect(calls.b).to.equal(2); 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /src/use-composed-ref/use-composed-ref.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "@rbxts/react"; 2 | import { useLatestCallback } from "../use-latest-callback"; 3 | 4 | export type RefFunction = (rbx?: T) => void; 5 | 6 | /** 7 | * Composes multiple ref functions into a single ref function and memoizes 8 | * the result. 9 | * 10 | * To prevent excess ref calls, the composed ref is only created once on mount. 11 | * However, it will call the latest refs passed, so it is safe to pass in refs 12 | * that might change. 13 | * 14 | * @param refs The ref functions to compose. 15 | * @returns A ref function that calls all of the ref functions passed in. 16 | */ 17 | export function useComposedRef(...refs: (RefFunction | undefined)[]): RefFunction { 18 | const composedRef = useMemo(() => { 19 | return composeRefs(...refs); 20 | }, refs); 21 | 22 | // Make sure the function returned never changes when dependencies change. 23 | // Otherwise, the ref will be called again, and might cause other problems. 24 | return useLatestCallback(composedRef); 25 | } 26 | 27 | /** 28 | * Composes multiple ref functions into a single ref function. 29 | * @param refs The ref functions to compose. 30 | * @returns A ref function that calls all of the ref functions passed in. 31 | */ 32 | export function composeRefs(...refs: (RefFunction | undefined)[]): RefFunction { 33 | const refsDefined = refs.filterUndefined(); 34 | 35 | return (rbx) => { 36 | for (const ref of refsDefined) { 37 | ref(rbx); 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/use-debounce-callback/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDebounceCallback` 2 | 3 | ```ts 4 | function useDebounceCallback(callback: T, options?: UseDebounceOptions): UseDebounceResult; 5 | ``` 6 | 7 | Creates a debounced function that delays invoking `callback` until after `wait` seconds have elapsed since the last time the debounced function was invoked. 8 | 9 | The callback is invoked with the last arguments provided to the debounced function. Subsequent calls to the debounced function return the result of the last `callback` invocation. 10 | 11 | See [lodash.debounce](https://lodash.com/docs/4.17.15#debounce) for the function this hook is based on. 12 | 13 | ### 📕 Parameters 14 | 15 | - `callback` - The function to debounce. 16 | - `options` - The options object. 17 | - `wait` - The number of seconds to delay. Defaults to `0`. 18 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `false`. 19 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 20 | - `maxWait` - The maximum time `state` is allowed to be delayed before invoking. 21 | 22 | ### 📗 Returns 23 | 24 | - A `UseDebounceResult` object. 25 | - `run` - The debounced function. 26 | - `cancel` - Cancels any pending invocation. 27 | - `flush` - Immediately invokes a pending invocation. 28 | - `pending` - Whether there is a pending invocation. 29 | 30 | ### 📘 Example 31 | 32 | Update the query after the user stops typing for 1 second. 33 | 34 | ```tsx 35 | function SearchQuery() { 36 | const [query, setQuery] = useState(""); 37 | 38 | const debounced = useDebounceCallback((value: string) => { 39 | setQuery(value); 40 | }, 1); 41 | 42 | return ( 43 | debounced.run(rbx.Text), 47 | }} 48 | /> 49 | ); 50 | } 51 | ``` 52 | -------------------------------------------------------------------------------- /src/use-debounce-callback/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-debounce-callback"; 2 | -------------------------------------------------------------------------------- /src/use-debounce-callback/use-debounce-callback.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useDebounceCallback } from "./use-debounce-callback"; 5 | 6 | export = () => { 7 | it("should return run, cancel, and flush", () => { 8 | let count = 0; 9 | const { result } = renderHook(() => useDebounceCallback((amount: number) => (count += amount), { wait: 0.02 })); 10 | 11 | result.current.run(1); 12 | result.current.run(1); 13 | result.current.run(4); 14 | result.current.run(2); 15 | expect(count).to.equal(0); 16 | 17 | task.wait(0.04); 18 | expect(count).to.equal(2); 19 | result.current.run(4); 20 | expect(count).to.equal(2); 21 | 22 | task.wait(0.04); 23 | expect(count).to.equal(6); 24 | result.current.run(4); 25 | expect(count).to.equal(6); 26 | result.current.cancel(); 27 | expect(count).to.equal(6); 28 | 29 | task.wait(0.04); 30 | expect(count).to.equal(6); 31 | result.current.run(1); 32 | expect(count).to.equal(6); 33 | result.current.flush(); 34 | expect(count).to.equal(7); 35 | task.wait(0.04); 36 | expect(count).to.equal(7); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/use-debounce-callback/use-debounce-callback.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "@rbxts/react"; 2 | import { DebounceOptions, Debounced, debounce } from "@rbxts/set-timeout"; 3 | import { useLatest } from "../use-latest"; 4 | import { useUnmountEffect } from "../use-unmount-effect"; 5 | 6 | export interface UseDebounceOptions extends DebounceOptions { 7 | /** 8 | * The amount of time to wait before the first call. 9 | */ 10 | wait?: number; 11 | } 12 | 13 | export interface UseDebounceResult { 14 | /** 15 | * The debounced function. 16 | */ 17 | run: Debounced; 18 | /** 19 | * Cancels delayed invocations to the callback. 20 | */ 21 | cancel: () => void; 22 | /** 23 | * Immediately invokes delayed callback invocations. 24 | */ 25 | flush: () => void; 26 | /** 27 | * Returns whether any invocations are pending. 28 | */ 29 | pending: () => boolean; 30 | } 31 | 32 | /** 33 | * Creates a debounced function that delays invoking `callback` until after `wait` 34 | * seconds have elapsed since the last time the debounced function was invoked. 35 | * The `callback` is invoked with the last arguments provided to the debounced 36 | * function. Subsequent calls to the debounced function return the result of 37 | * the last `callback` invocation. 38 | * 39 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 40 | * for details over the differences between `debounce` and `throttle`. 41 | * 42 | * @param callback The function to debounce. 43 | * @param options The options object. 44 | * @returns The new debounced function. 45 | */ 46 | export function useDebounceCallback( 47 | callback: T, 48 | options: UseDebounceOptions = {}, 49 | ): UseDebounceResult { 50 | const callbackRef = useLatest(callback); 51 | 52 | const debounced = useMemo(() => { 53 | return debounce( 54 | (...args: unknown[]) => { 55 | return callbackRef.current(...args); 56 | }, 57 | options.wait, 58 | options, 59 | ); 60 | }, []) as Debounced; 61 | 62 | useUnmountEffect(() => { 63 | debounced.cancel(); 64 | }); 65 | 66 | return { 67 | run: debounced, 68 | cancel: debounced.cancel, 69 | flush: debounced.flush, 70 | pending: debounced.pending, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/use-debounce-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDebounceEffect` 2 | 3 | ```ts 4 | function useDebounceEffect( 5 | effect: () => (() => void) | void, 6 | dependencies?: unknown[], 7 | options?: UseDebounceOptions, 8 | ): void; 9 | ``` 10 | 11 | Creates a debounced effect that delays invoking `effect` until after `wait` seconds have elapsed since the last time the debounced function was invoked. 12 | 13 | See [lodash.debounce](https://lodash.com/docs/4.17.15#debounce) for the function this hook is based on. 14 | 15 | ### 📕 Parameters 16 | 17 | - `effect` - The effect to debounce. 18 | - `dependencies` - The dependencies array. 19 | - `options` - The options object. 20 | - `wait` - The number of seconds to delay. Defaults to `0`. 21 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `false`. 22 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 23 | - `maxWait` - The maximum time `state` is allowed to be delayed before invoking. 24 | 25 | ### 📗 Returns 26 | 27 | - `void` 28 | 29 | ### 📘 Example 30 | 31 | Update the query after the user stops typing for 1 second. 32 | 33 | ```tsx 34 | function SearchQuery() { 35 | const [query, setQuery] = useState(""); 36 | 37 | useDebounceEffect( 38 | () => { 39 | print(query); 40 | }, 41 | [query], 42 | { wait: 1 }, 43 | ); 44 | 45 | return ( 46 | setQuery(rbx.Text), 50 | }} 51 | /> 52 | ); 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /src/use-debounce-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-debounce-effect"; 2 | -------------------------------------------------------------------------------- /src/use-debounce-effect/use-debounce-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useDebounceEffect } from "./use-debounce-effect"; 5 | 6 | export = () => { 7 | it("should debounce the effect", () => { 8 | let count = 0; 9 | const { rerender, unmount } = renderHook( 10 | ({ input }) => 11 | useDebounceEffect( 12 | () => { 13 | count += 1; 14 | }, 15 | [input], 16 | { wait: 0.02 }, 17 | ), 18 | { initialProps: { input: 0 } }, 19 | ); 20 | 21 | rerender({ input: 0 }); 22 | rerender({ input: 1 }); 23 | rerender({ input: 0 }); 24 | rerender({ input: 1 }); 25 | expect(count).to.equal(0); 26 | 27 | task.wait(0.04); 28 | expect(count).to.equal(1); 29 | rerender({ input: 2 }); 30 | expect(count).to.equal(1); 31 | 32 | task.wait(0.04); 33 | expect(count).to.equal(2); 34 | rerender({ input: 2 }); 35 | expect(count).to.equal(2); 36 | 37 | task.wait(0.04); 38 | expect(count).to.equal(2); 39 | rerender({ input: 3 }); 40 | expect(count).to.equal(2); 41 | rerender({ input: 4 }); 42 | expect(count).to.equal(2); 43 | 44 | task.wait(0.04); 45 | expect(count).to.equal(3); 46 | 47 | unmount(); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/use-debounce-effect/use-debounce-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { UseDebounceOptions, useDebounceCallback } from "../use-debounce-callback"; 3 | import { useUpdate } from "../use-update"; 4 | import { useUpdateEffect } from "../use-update-effect"; 5 | 6 | /** 7 | * Creates a debounced effect that delays invoking `effect` until after `wait` 8 | * seconds have elapsed since the last time the debounced function was invoked. 9 | * 10 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 11 | * for details over the differences between `debounce` and `throttle`. 12 | * 13 | * @param effect The effect to debounce. 14 | * @param dependencies The dependencies array. 15 | * @param options The options object. 16 | */ 17 | export function useDebounceEffect( 18 | effect: () => (() => void) | void, 19 | dependencies?: unknown[], 20 | options?: UseDebounceOptions, 21 | ) { 22 | const update = useUpdate(); 23 | 24 | const { run } = useDebounceCallback(update, options); 25 | 26 | useEffect(() => { 27 | return run(); 28 | }, dependencies); 29 | 30 | useUpdateEffect(effect, [update]); 31 | } 32 | -------------------------------------------------------------------------------- /src/use-debounce-state/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDebounceState` 2 | 3 | ```ts 4 | function useDebounceState(initialState: T, options?: UseDebounceOptions): LuaTuple<[T, Dispatch>]>; 5 | ``` 6 | 7 | Delays updating `state` until after `wait` seconds have elapsed since the last time the debounced function was invoked. Set to the most recently passed `state` after the delay. 8 | 9 | See [lodash.debounce](https://lodash.com/docs/4.17.15#debounce) for the function this hook is based on. 10 | 11 | ### 📕 Parameters 12 | 13 | - `initialState` - The initial state. 14 | - `options` - The options object. 15 | - `wait` - The number of seconds to delay. Defaults to `0`. 16 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `false`. 17 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 18 | - `maxWait` - The maximum time `state` is allowed to be delayed before invoking. 19 | 20 | ### 📗 Returns 21 | 22 | - The debounced state. 23 | - A function to update the debounced state. 24 | 25 | ### 📘 Example 26 | 27 | Update the query after the user stops typing for 1 second. 28 | 29 | ```tsx 30 | function SearchQuery() { 31 | const [query, setQuery] = useDebounceState("", { wait: 1 }); 32 | 33 | useEffect(() => { 34 | print(query); 35 | }, [query]); 36 | 37 | return ( 38 | setQuery(rbx.Text), 42 | }} 43 | /> 44 | ); 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /src/use-debounce-state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-debounce-state"; 2 | -------------------------------------------------------------------------------- /src/use-debounce-state/use-debounce-state.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useDebounceState } from "./use-debounce-state"; 5 | 6 | export = () => { 7 | it("should debounce the state", () => { 8 | const { result, unmount } = renderHook(() => { 9 | const [state, setState] = useDebounceState(0, { wait: 0.02 }); 10 | return { state, setState }; 11 | }); 12 | 13 | expect(result.current.state).to.equal(0); 14 | result.current.setState(0); 15 | result.current.setState(1); 16 | result.current.setState(0); 17 | result.current.setState(1); 18 | expect(result.current.state).to.equal(0); 19 | 20 | task.wait(0.04); 21 | expect(result.current.state).to.equal(1); 22 | result.current.setState(2); 23 | expect(result.current.state).to.equal(1); 24 | 25 | task.wait(0.04); 26 | expect(result.current.state).to.equal(2); 27 | result.current.setState(2); 28 | expect(result.current.state).to.equal(2); 29 | 30 | task.wait(0.04); 31 | expect(result.current.state).to.equal(2); 32 | result.current.setState(3); 33 | expect(result.current.state).to.equal(2); 34 | result.current.setState(4); 35 | expect(result.current.state).to.equal(2); 36 | 37 | task.wait(0.04); 38 | expect(result.current.state).to.equal(4); 39 | 40 | unmount(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/use-debounce-state/use-debounce-state.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from "@rbxts/react"; 2 | import { UseDebounceOptions, useDebounceCallback } from "../use-debounce-callback"; 3 | 4 | /** 5 | * Delays updating `state` until after `wait` seconds have elapsed since the 6 | * last time the debounced function was invoked. Set to the most recently passed 7 | * `state` after the delay. 8 | * 9 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 10 | * for details over the differences between `debounce` and `throttle`. 11 | * 12 | * @param initialState The value to debounce. 13 | * @param options The options object. 14 | * @returns A tuple containing the debounced value and a function to update it. 15 | */ 16 | export function useDebounceState( 17 | initialState: T, 18 | options?: UseDebounceOptions, 19 | ): LuaTuple<[T, Dispatch>]> { 20 | const [state, setState] = useState(initialState); 21 | 22 | return $tuple(state, useDebounceCallback(setState, options).run); 23 | } 24 | -------------------------------------------------------------------------------- /src/use-defer-callback/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDeferCallback` 2 | 3 | ```ts 4 | function useDeferCallback( 5 | callback: (...args: T) => void, 6 | ): [execute: (...args: T) => void, cleanup: () => void]; 7 | ``` 8 | 9 | Defers a callback until the next Heartbeat frame. Consecutive calls to the returned `execute` function will cancel the previous call. 10 | 11 | ### 📕 Parameters 12 | 13 | - `callback` - The function to defer. 14 | 15 | ### 📗 Returns 16 | 17 | - `execute` - The deferred function. 18 | - `cleanup` - A function that will cancel a scheduled update. 19 | 20 | ### 📘 Example 21 | 22 | ```tsx 23 | function Counter() { 24 | const [count, setCount] = useState(0); 25 | 26 | const [deferredSetCount, cancel] = useDeferCallback(setCount); 27 | 28 | useEventListener(UserInputService.InputBegan, (input) => { 29 | if (input.KeyCode === Enum.KeyCode.E) { 30 | deferredSetCount(count + 1); 31 | } 32 | }); 33 | 34 | useUnmountEffect(cancel); 35 | 36 | return ; 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /src/use-defer-callback/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-defer-callback"; 2 | -------------------------------------------------------------------------------- /src/use-defer-callback/use-defer-callback.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { RunService } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useDeferCallback } from "./use-defer-callback"; 6 | 7 | export = () => { 8 | const wait = () => { 9 | RunService.Heartbeat.Wait(); 10 | RunService.Heartbeat.Wait(); 11 | }; 12 | 13 | it("should return a callback and a cancel function", () => { 14 | const { result } = renderHook(() => { 15 | const [callback, cancel] = useDeferCallback(() => {}); 16 | return { callback, cancel }; 17 | }); 18 | 19 | expect(result.current.callback).to.be.a("function"); 20 | expect(result.current.cancel).to.be.a("function"); 21 | }); 22 | 23 | it("should execute the callback on the next heartbeat", () => { 24 | let calls = 0; 25 | 26 | const { result } = renderHook(() => { 27 | const [callback] = useDeferCallback(() => calls++); 28 | return { callback }; 29 | }); 30 | 31 | result.current.callback(); 32 | expect(calls).to.equal(0); 33 | 34 | wait(); 35 | expect(calls).to.equal(1); 36 | }); 37 | 38 | it("should return a function that cancels the callback", () => { 39 | let calls = 0; 40 | 41 | const { result } = renderHook(() => { 42 | const [callback, cancel] = useDeferCallback(() => calls++); 43 | return { callback, cancel }; 44 | }); 45 | 46 | result.current.callback(); 47 | expect(calls).to.equal(0); 48 | 49 | result.current.cancel(); 50 | wait(); 51 | expect(calls).to.equal(0); 52 | }); 53 | 54 | it("should cancel the previous callback when called again", () => { 55 | let calls = 0; 56 | 57 | const { result } = renderHook(() => { 58 | const [callback] = useDeferCallback(() => calls++); 59 | return { callback }; 60 | }); 61 | 62 | result.current.callback(); 63 | result.current.callback(); 64 | result.current.callback(); 65 | expect(calls).to.equal(0); 66 | 67 | wait(); 68 | expect(calls).to.equal(1); 69 | }); 70 | 71 | it("should execute the callback with the latest arguments", () => { 72 | let calls = 0; 73 | 74 | const { result } = renderHook(() => { 75 | const [callback] = useDeferCallback((value: number) => (calls += value)); 76 | return { callback }; 77 | }); 78 | 79 | result.current.callback(1); 80 | result.current.callback(2); 81 | result.current.callback(3); 82 | expect(calls).to.equal(0); 83 | 84 | wait(); 85 | expect(calls).to.equal(3); 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /src/use-defer-callback/use-defer-callback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "@rbxts/react"; 2 | import { RunService } from "@rbxts/services"; 3 | import { useLatest } from "../use-latest"; 4 | 5 | /** 6 | * Defers a callback to be executed on the next Heartbeat frame. Consecutive 7 | * calls to the returned `execute` function will cancel the previous call. 8 | * @param callback The callback to defer 9 | * @returns A tuple containing the `execute` and `cancel` functions 10 | */ 11 | export function useDeferCallback( 12 | callback: (...args: T) => void, 13 | ): LuaTuple<[execute: (...args: T) => void, cancel: () => void]> { 14 | const callbackRef = useLatest(callback); 15 | const connectionRef = useRef(); 16 | 17 | const cancel = useCallback(() => { 18 | connectionRef.current?.Disconnect(); 19 | connectionRef.current = undefined; 20 | }, []); 21 | 22 | const execute = useCallback((...args: T) => { 23 | cancel(); 24 | 25 | connectionRef.current = RunService.Heartbeat.Once(() => { 26 | connectionRef.current = undefined; 27 | callbackRef.current(...args); 28 | }); 29 | }, []); 30 | 31 | return $tuple(execute, cancel); 32 | } 33 | -------------------------------------------------------------------------------- /src/use-defer-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDeferEffect` 2 | 3 | ```ts 4 | function useDeferEffect(callback: () => void, deps?: unknown[]): void; 5 | ``` 6 | 7 | Like `useEffect`, but the callback will defer the update until the next Heartbeat frame. If multiple updates are scheduled, only the most recent will be applied. 8 | 9 | ### 📕 Parameters 10 | 11 | - `callback` - A function to run after the component renders. 12 | - `deps` - An array of values that the effect depends on. If any of the values change, the effect will run again. 13 | 14 | ### 📘 Example 15 | 16 | ```tsx 17 | function Counter() { 18 | const [count, setCount] = useState(0); 19 | 20 | useDeferEffect(() => { 21 | print(count); 22 | }, [count]); 23 | 24 | return ( 25 | setCount((count) => count + 1), 29 | }} 30 | /> 31 | ); 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /src/use-defer-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-defer-effect"; 2 | -------------------------------------------------------------------------------- /src/use-defer-effect/use-defer-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { RunService } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useDeferEffect } from "./use-defer-effect"; 6 | 7 | export = () => { 8 | const wait = () => { 9 | RunService.Heartbeat.Wait(); 10 | RunService.Heartbeat.Wait(); 11 | }; 12 | 13 | it("should run the effect on the next heartbeat", () => { 14 | let calls = 0; 15 | 16 | const { rerender, unmount } = renderHook(() => { 17 | useDeferEffect(() => calls++); 18 | }); 19 | 20 | expect(calls).to.equal(0); 21 | 22 | wait(); 23 | expect(calls).to.equal(1); 24 | 25 | wait(); 26 | expect(calls).to.equal(1); 27 | 28 | rerender(); 29 | expect(calls).to.equal(1); 30 | 31 | wait(); 32 | expect(calls).to.equal(2); 33 | 34 | unmount(); 35 | }); 36 | 37 | it("should run the effect on dependency change", () => { 38 | let calls = 0; 39 | 40 | const { unmount, rerender } = renderHook( 41 | (value: number) => { 42 | useDeferEffect(() => calls++, [value]); 43 | }, 44 | { initialProps: 0 }, 45 | ); 46 | 47 | expect(calls).to.equal(0); 48 | 49 | wait(); 50 | expect(calls).to.equal(1); 51 | 52 | rerender(1); 53 | expect(calls).to.equal(1); 54 | 55 | wait(); 56 | expect(calls).to.equal(2); 57 | 58 | unmount(); 59 | }); 60 | 61 | it("should cancel the effect on unmount", () => { 62 | let calls = 0; 63 | 64 | const { unmount, rerender } = renderHook(() => { 65 | useDeferEffect(() => calls++); 66 | }); 67 | 68 | expect(calls).to.equal(0); 69 | 70 | wait(); 71 | expect(calls).to.equal(1); 72 | 73 | rerender(); 74 | unmount(); 75 | 76 | wait(); 77 | expect(calls).to.equal(1); 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /src/use-defer-effect/use-defer-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { useDeferCallback } from "../use-defer-callback"; 3 | 4 | /** 5 | * Like `useEffect`, but the callback is deferred to the next Heartbeat frame. 6 | * @param callback The callback to run 7 | * @param dependencies Optional dependencies to trigger the effect 8 | */ 9 | export function useDeferEffect(callback: () => void, dependencies?: unknown[]) { 10 | const [deferredCallback, cancel] = useDeferCallback(callback); 11 | 12 | useEffect(() => { 13 | deferredCallback(); 14 | return cancel; 15 | }, dependencies); 16 | } 17 | -------------------------------------------------------------------------------- /src/use-defer-state/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useDeferState` 2 | 3 | ```ts 4 | function useDeferState(initialState: T | (() => T)): [state: T, setState: Dispatch>]; 5 | 6 | function useDeferState( 7 | initialState?: void, 8 | ): [state: T | undefined, setState: Dispatch>]; 9 | ``` 10 | 11 | Like `useState`, but the updater function will defer the update until the next Heartbeat frame. Only the result of the most recent state update will be applied. 12 | 13 | When passing a function to `setState`, it will receive the most recent value passed to `setState`, so they can be chained. 14 | 15 | This is useful for improving performance when updating state in response to events that fire rapidly in succession. 16 | 17 | ### 📕 Parameters 18 | 19 | - `initialState` - State used during the initial render. 20 | 21 | ### 📗 Returns 22 | 23 | - A stateful value. 24 | - A function which schedules a state update. 25 | 26 | ### 📘 Example 27 | 28 | ```tsx 29 | function Counter() { 30 | const [keysDown, setKeysDown] = useDeferState([]); 31 | 32 | useEventListener(UserInputService.InputBegan, (input) => { 33 | setKeysDown((keysDown) => [...keysDown, input.KeyCode.Name]); 34 | }); 35 | 36 | useEventListener(UserInputService.InputEnded, (input) => { 37 | setKeysDown((keysDown) => keysDown.filter((key) => key !== input.KeyCode.Name)); 38 | }); 39 | 40 | useEffect(() => { 41 | print(keysDown); 42 | }, [keysDown]); 43 | 44 | return ; 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /src/use-defer-state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-defer-state"; 2 | -------------------------------------------------------------------------------- /src/use-defer-state/use-defer-state.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { RunService } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useDeferState } from "./use-defer-state"; 6 | 7 | export = () => { 8 | const wait = () => { 9 | RunService.Heartbeat.Wait(); 10 | RunService.Heartbeat.Wait(); 11 | }; 12 | 13 | it("should return the state and a setter", () => { 14 | const { result } = renderHook(() => { 15 | const [state, setState] = useDeferState(0); 16 | return { state, setState }; 17 | }); 18 | 19 | expect(result.current.state).to.equal(0); 20 | expect(result.current.setState).to.be.a("function"); 21 | }); 22 | 23 | it("should update the state on heartbeat", () => { 24 | const { result } = renderHook(() => { 25 | const [state, setState] = useDeferState(0); 26 | return { state, setState }; 27 | }); 28 | 29 | result.current.setState(1); 30 | expect(result.current.state).to.equal(0); 31 | 32 | wait(); 33 | expect(result.current.state).to.equal(1); 34 | }); 35 | 36 | it("should only update the state once per frame", () => { 37 | const { result } = renderHook(() => { 38 | const [state, setState] = useDeferState(0); 39 | return { state, setState }; 40 | }); 41 | 42 | result.current.setState(1); 43 | result.current.setState(2); 44 | result.current.setState(3); 45 | expect(result.current.state).to.equal(0); 46 | 47 | wait(); 48 | expect(result.current.state).to.equal(3); 49 | }); 50 | 51 | it("should receive a function to update state", () => { 52 | const { result } = renderHook(() => { 53 | const [state, setState] = useDeferState(0); 54 | return { state, setState }; 55 | }); 56 | 57 | result.current.setState((state) => state + 1); 58 | result.current.setState((state) => state + 1); 59 | result.current.setState((state) => state + 1); 60 | expect(result.current.state).to.equal(0); 61 | 62 | wait(); 63 | expect(result.current.state).to.equal(3); 64 | }); 65 | 66 | it("should only rerender once per frame", () => { 67 | let renderCount = 0; 68 | const { result } = renderHook(() => { 69 | const [state, setState] = useDeferState(0); 70 | renderCount++; 71 | return { state, setState }; 72 | }); 73 | 74 | expect(renderCount).to.equal(1); 75 | 76 | result.current.setState((state) => state + 1); 77 | result.current.setState((state) => state + 1); 78 | result.current.setState((state) => state + 1); 79 | expect(renderCount).to.equal(1); 80 | 81 | wait(); 82 | expect(renderCount).to.equal(2); 83 | }); 84 | 85 | it("should cancel the update on unmount", () => { 86 | const { result, unmount } = renderHook(() => { 87 | const [state, setState] = useDeferState(0); 88 | return { state, setState }; 89 | }); 90 | 91 | result.current.setState(1); 92 | expect(result.current.state).to.equal(0); 93 | 94 | unmount(); 95 | wait(); 96 | expect(result.current.state).to.equal(0); 97 | }); 98 | }; 99 | -------------------------------------------------------------------------------- /src/use-defer-state/use-defer-state.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useCallback, useState } from "@rbxts/react"; 2 | import { useDeferCallback } from "../use-defer-callback"; 3 | import { useLatest } from "../use-latest"; 4 | import { useUnmountEffect } from "../use-unmount-effect"; 5 | 6 | const resolve = (value: T | ((state: T) => T), state: T): T => { 7 | return typeIs(value, "function") ? value(state) : value; 8 | }; 9 | 10 | /** 11 | * Like `useState`, but `setState` will update the state on the next Heartbeat 12 | * frame. Only the latest update in a frame will run. 13 | * 14 | * This is useful for improving performance when updating state in response to 15 | * events that fire rapidly in succession. 16 | * 17 | * @param initialState Optional initial state 18 | * @returns A tuple containing the state and a function to update it 19 | */ 20 | export function useDeferState( 21 | initialState: T | (() => T), 22 | ): LuaTuple<[state: T, setState: Dispatch>]>; 23 | 24 | export function useDeferState( 25 | initialState?: void, 26 | ): LuaTuple<[state: T | undefined, setState: Dispatch>]>; 27 | 28 | export function useDeferState( 29 | initialState: T | (() => T), 30 | ): LuaTuple<[state: T, setState: Dispatch>]> { 31 | const [state, innerSetState] = useState(initialState); 32 | const [deferredSetState, cancel] = useDeferCallback(innerSetState); 33 | 34 | const latestState = useLatest(state); 35 | 36 | // Wrap 'deferState' to allow multiple changes to state in one frame through 37 | // the `latestState` reference 38 | const setState = useCallback((value: SetStateAction) => { 39 | latestState.current = resolve(value, latestState.current); 40 | deferredSetState(latestState.current); 41 | }, []); 42 | 43 | useUnmountEffect(cancel); 44 | 45 | return $tuple(state, setState); 46 | } 47 | -------------------------------------------------------------------------------- /src/use-event-listener/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useEventListener` 2 | 3 | ```ts 4 | function useEventListener( 5 | event?: T, 6 | listener?: T extends EventLike ? U : never, 7 | options: EventListenerOptions = {}, 8 | ): void; 9 | ``` 10 | 11 | Connects an event listener to the given event. The event can be any object with a `Connect` method that returns a Connection object or a cleanup function. 12 | 13 | If the event or the listener is `undefined`, the previous listener will be disconnected. 14 | 15 | The `listener` parameter is memoized for you, and will not cause a reconnect if it changes. 16 | 17 | ### ⚙️ Options 18 | 19 | - `connected` - Whether the listener should be connected. Defaults to `true`. 20 | - `once` - Whether the listener should be disconnected after the first time it is called. Defaults to `false`. 21 | 22 | ### 📕 Parameters 23 | 24 | - `event` - The event to listen to. 25 | - `listener` - The listener to call when the event fires. 26 | - `options` - Optional config for the listener. 27 | 28 | ### 📗 Returns 29 | 30 | - `void` 31 | 32 | ### 📘 Example 33 | 34 | ```tsx 35 | function PlayerJoined() { 36 | useEventListener(Players.PlayerAdded, (player) => { 37 | print(`${player.DisplayName} joined!`); 38 | }); 39 | 40 | return ; 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /src/use-event-listener/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-event-listener"; 2 | -------------------------------------------------------------------------------- /src/use-event-listener/use-event-listener.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useEventListener } from "./use-event-listener"; 5 | 6 | function createSignal() { 7 | const listeners = new Set<(...args: T) => void>(); 8 | 9 | return { 10 | listeners() { 11 | return listeners; 12 | }, 13 | 14 | connect(listener: (...args: T) => void) { 15 | listeners.add(listener); 16 | return () => listeners.delete(listener); 17 | }, 18 | 19 | fire(...args: T) { 20 | for (const listener of listeners) { 21 | listener(...args); 22 | } 23 | }, 24 | }; 25 | } 26 | 27 | export = () => { 28 | it("should connect on mount", () => { 29 | const signal = createSignal(); 30 | const { unmount } = renderHook(() => useEventListener(signal, () => {})); 31 | 32 | expect(signal.listeners().size()).to.equal(1); 33 | unmount(); 34 | }); 35 | 36 | it("should disconnect on unmount", () => { 37 | const signal = createSignal(); 38 | const { unmount } = renderHook(() => useEventListener(signal, () => {})); 39 | 40 | unmount(); 41 | expect(signal.listeners().size()).to.equal(0); 42 | }); 43 | 44 | it("should clean up old connections", () => { 45 | const signalA = createSignal(); 46 | const signalB = createSignal(); 47 | const { rerender, unmount } = renderHook( 48 | ({ signal }: { signal: ReturnType }) => useEventListener(signal, () => {}), 49 | { initialProps: { signal: signalA } }, 50 | ); 51 | 52 | rerender({ signal: signalB }); 53 | expect(signalA.listeners().size()).to.equal(0); 54 | expect(signalB.listeners().size()).to.equal(1); 55 | 56 | rerender({ signal: signalA }); 57 | expect(signalA.listeners().size()).to.equal(1); 58 | expect(signalB.listeners().size()).to.equal(0); 59 | 60 | unmount(); 61 | expect(signalA.listeners().size()).to.equal(0); 62 | expect(signalB.listeners().size()).to.equal(0); 63 | }); 64 | 65 | it("should call listener on event", () => { 66 | const signal = createSignal<[number]>(); 67 | let result: number | undefined; 68 | 69 | const { unmount } = renderHook(() => useEventListener(signal, (value) => (result = value))); 70 | 71 | signal.fire(0); 72 | expect(result).to.equal(0); 73 | 74 | signal.fire(1); 75 | expect(result).to.equal(1); 76 | 77 | unmount(); 78 | }); 79 | 80 | it("should receive a 'once' option", () => { 81 | const signal = createSignal(); 82 | let calls = 0; 83 | 84 | const { rerender, unmount } = renderHook(() => useEventListener(signal, () => calls++, { once: true })); 85 | 86 | signal.fire(); 87 | rerender(); 88 | signal.fire(); 89 | rerender(); 90 | signal.fire(); 91 | expect(calls).to.equal(1); 92 | unmount(); 93 | }); 94 | 95 | it("should receive a 'connected' option", () => { 96 | const signal = createSignal(); 97 | let calls = 0; 98 | 99 | const { rerender, unmount } = renderHook( 100 | ({ connected }) => useEventListener(signal, () => calls++, { connected }), 101 | { initialProps: { connected: true } }, 102 | ); 103 | 104 | signal.fire(); 105 | rerender({ connected: false }); 106 | signal.fire(); 107 | rerender({ connected: true }); 108 | signal.fire(); 109 | expect(calls).to.equal(2); 110 | unmount(); 111 | }); 112 | }; 113 | -------------------------------------------------------------------------------- /src/use-event-listener/use-event-listener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { useLatest } from "../use-latest"; 3 | 4 | interface EventListenerOptions { 5 | /** 6 | * Whether the event should be connected or not. Defaults to `true`. 7 | */ 8 | connected?: boolean; 9 | /** 10 | * Whether the event should be disconnected after the first invocation. 11 | * Defaults to `false`. 12 | */ 13 | once?: boolean; 14 | } 15 | 16 | type EventLike = 17 | | { Connect(callback: T): ConnectionLike } 18 | | { connect(callback: T): ConnectionLike } 19 | | { subscribe(callback: T): ConnectionLike }; 20 | 21 | type ConnectionLike = { Disconnect(): void } | { disconnect(): void } | (() => void); 22 | 23 | const connect = (event: EventLike, callback: Callback): ConnectionLike => { 24 | if (typeIs(event, "RBXScriptSignal")) { 25 | // With deferred events, a "hard disconnect" is necessary to avoid causing 26 | // state updates after a component unmounts. Use 'Connected' to check if 27 | // the connection is still valid before invoking the callback. 28 | // https://devforum.roblox.com/t/deferred-engine-events/2276564/99 29 | const connection = event.Connect((...args: unknown[]) => { 30 | if (connection.Connected) { 31 | return callback(...args); 32 | } 33 | }); 34 | return connection; 35 | } else if ("Connect" in event) { 36 | return event.Connect(callback); 37 | } else if ("connect" in event) { 38 | return event.connect(callback); 39 | } else if ("subscribe" in event) { 40 | return event.subscribe(callback); 41 | } else { 42 | throw "Event-like object does not have a supported connect method."; 43 | } 44 | }; 45 | 46 | const disconnect = (connection: ConnectionLike) => { 47 | if (typeIs(connection, "function")) { 48 | connection(); 49 | } else if (typeIs(connection, "RBXScriptConnection") || "Disconnect" in connection) { 50 | connection.Disconnect(); 51 | } else if ("disconnect" in connection) { 52 | connection.disconnect(); 53 | } else { 54 | throw "Connection-like object does not have a supported disconnect method."; 55 | } 56 | }; 57 | 58 | /** 59 | * Subscribes to an event-like object. The subscription is automatically 60 | * disconnected when the component unmounts. 61 | * 62 | * If the event or listener is `undefined`, the event will not be subscribed to, 63 | * and the subscription will be disconnected if it was previously connected. 64 | * 65 | * The listener is memoized, so it is safe to pass a callback that is recreated 66 | * on every render. 67 | * 68 | * @param event The event-like object to subscribe to. 69 | * @param listener The listener to subscribe with. 70 | * @param options Options for the subscription. 71 | */ 72 | export function useEventListener( 73 | event?: T, 74 | listener?: T extends EventLike ? U : never, 75 | options: EventListenerOptions = {}, 76 | ) { 77 | const { once = false, connected = true } = options; 78 | 79 | const listenerRef = useLatest(listener); 80 | 81 | useEffect(() => { 82 | if (!event || !listener || !connected) { 83 | return; 84 | } 85 | 86 | let canDisconnect = true; 87 | 88 | const connection = connect(event, (...args: unknown[]) => { 89 | if (once) { 90 | disconnect(connection); 91 | canDisconnect = false; 92 | } 93 | listenerRef.current?.(...args); 94 | }); 95 | 96 | return () => { 97 | if (canDisconnect) { 98 | disconnect(connection); 99 | } 100 | }; 101 | }, [event, connected, listener !== undefined]); 102 | } 103 | -------------------------------------------------------------------------------- /src/use-interval/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useInterval` 2 | 3 | ```ts 4 | function useInterval(callback: () => void, delay?: number, options: UseIntervalOptions): () => void; 5 | ``` 6 | 7 | Sets an interval that runs a callback function every `delay` seconds. Returns a function that clears the interval. 8 | 9 | If `delay` is `undefined`, the interval is cleared. If the delay updates, the interval is reset. 10 | 11 | The callback is memoized for you and will not reset the interval if it changes. 12 | 13 | ### 📕 Parameters 14 | 15 | - `callback` - The function to run every `delay` seconds. 16 | - `delay` - The number of seconds to wait between each call to `callback`. If `undefined`, the interval is cleared. 17 | - `options` - Options for the interval. 18 | - `immediate` - If `true`, the callback is called immediately when the interval is set. Defaults to `false`. 19 | 20 | ### 📗 Returns 21 | 22 | - A function that clears the interval. 23 | 24 | ### 📘 Example 25 | 26 | ```tsx 27 | function Interval() { 28 | const [count, setCount] = useState(0); 29 | 30 | useInterval(() => { 31 | setCount(count + 1); 32 | }, 1); 33 | 34 | return ; 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /src/use-interval/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-interval"; 2 | -------------------------------------------------------------------------------- /src/use-interval/use-interval.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useInterval } from "./use-interval"; 5 | 6 | export = () => { 7 | it("should run the callback after the delay", () => { 8 | let count = 0; 9 | const { unmount } = renderHook(() => { 10 | useInterval(() => count++, 0.03); 11 | }); 12 | 13 | expect(count).to.equal(0); 14 | 15 | task.wait(0.04); 16 | expect(count).to.equal(1); 17 | 18 | task.wait(0.04); 19 | expect(count).to.equal(2); 20 | unmount(); 21 | }); 22 | 23 | it("should clear when delay is undefined", () => { 24 | let count = 0; 25 | const { rerender, unmount } = renderHook( 26 | ({ delay }) => { 27 | useInterval(() => count++, delay); 28 | }, 29 | { initialProps: { delay: 0.06 as number | undefined } }, 30 | ); 31 | 32 | expect(count).to.equal(0); 33 | 34 | task.wait(0.02); 35 | expect(count).to.equal(0); 36 | rerender({ delay: undefined }); 37 | 38 | task.wait(0.08); 39 | expect(count).to.equal(0); 40 | unmount(); 41 | }); 42 | 43 | it("should clear on unmount", () => { 44 | let count = 0; 45 | const { unmount } = renderHook(() => { 46 | useInterval(() => count++, 0.06); 47 | }); 48 | 49 | expect(count).to.equal(0); 50 | 51 | task.wait(0.02); 52 | expect(count).to.equal(0); 53 | unmount(); 54 | 55 | task.wait(0.08); 56 | expect(count).to.equal(0); 57 | }); 58 | 59 | it("should reset when delay updates", () => { 60 | let count = 0; 61 | const { rerender, unmount } = renderHook( 62 | ({ delay }) => { 63 | useInterval(() => count++, delay); 64 | }, 65 | { initialProps: { delay: 0.06 as number | undefined } }, 66 | ); 67 | 68 | expect(count).to.equal(0); 69 | 70 | task.wait(0.02); 71 | expect(count).to.equal(0); 72 | rerender({ delay: 0.05 }); 73 | 74 | task.wait(0.02); 75 | expect(count).to.equal(0); 76 | 77 | task.wait(0.04); 78 | expect(count).to.equal(1); 79 | unmount(); 80 | }); 81 | 82 | it("should return a clear function", () => { 83 | let count = 0; 84 | const { result, unmount } = renderHook(() => { 85 | return useInterval(() => count++, 0.06); 86 | }); 87 | 88 | expect(count).to.equal(0); 89 | 90 | task.wait(0.02); 91 | expect(count).to.equal(0); 92 | result.current(); 93 | 94 | task.wait(0.08); 95 | expect(count).to.equal(0); 96 | unmount(); 97 | }); 98 | }; 99 | -------------------------------------------------------------------------------- /src/use-interval/use-interval.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "@rbxts/react"; 2 | import { setInterval } from "@rbxts/set-timeout"; 3 | import { useLatestCallback } from "../use-latest-callback"; 4 | 5 | export interface UseIntervalOptions { 6 | /** 7 | * Whether the callback should run immediately when the interval is set. 8 | * Defaults to `false`. 9 | */ 10 | immediate?: boolean; 11 | } 12 | 13 | /** 14 | * Sets an interval that runs the callback function every `delay` seconds. If 15 | * `delay` is `undefined`, the interval is cleared. If the delay changes, the 16 | * interval is reset. 17 | * @param callback The callback function to run. 18 | * @param delay The delay in seconds between each interval. 19 | * @param options The options for the interval. 20 | * @returns A function that clears the interval. 21 | */ 22 | export function useInterval(callback: () => void, delay?: number, options: UseIntervalOptions = {}) { 23 | const { immediate = false } = options; 24 | 25 | const callbackMemo = useLatestCallback(callback); 26 | const cancel = useRef<() => void>(); 27 | 28 | const clear = useCallback(() => { 29 | cancel.current?.(); 30 | }, []); 31 | 32 | useEffect(() => { 33 | if (delay === undefined) { 34 | return; 35 | } 36 | if (immediate) { 37 | callbackMemo(); 38 | } 39 | cancel.current = setInterval(callbackMemo, delay); 40 | return clear; 41 | }, [delay]); 42 | 43 | return clear; 44 | } 45 | -------------------------------------------------------------------------------- /src/use-key-press/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useKeyPress` 2 | 3 | ```ts 4 | function useKeyPress(keyCodes: KeyCodes[], options?: KeyPressOptions): boolean; 5 | ``` 6 | 7 | Returns `true` if any of the given keys or shortcuts are pressed. The hook expects one or more key codes, which can be: 8 | 9 | - A single key, like `"Space"` 10 | - A combination of keys, like `"Space+W"` 11 | - An array of keys, like `["Space", "W"]` 12 | 13 | Each combination is treated as its own shortcut. If passed more than one key combination, the hook will return `true` if any of the combinations are pressed. 14 | 15 | ### 📕 Parameters 16 | 17 | - `keyCodes` - One or more key codes. 18 | - `options` - Optional options object. 19 | - `bindAction` - Whether to bind a ContextActionService action to the key press. Defaults to `false`. 20 | - `actionName` - The name of the action to bind. Defaults to a random string. 21 | - `actionPriority` - The priority of the action to bind. Defaults to `Enum.ContextActionPriority.High.Value`. 22 | - `actionInputTypes` - The input types of the action to bind. Defaults to Keyboard and Gamepad1. 23 | 24 | ### 📗 Returns 25 | 26 | - Whether any of the given keys or shortcuts are pressed. 27 | 28 | ### 📘 Example 29 | 30 | ```tsx 31 | function Keyboard() { 32 | const spacePressed = useKeyPress(["Space"]); 33 | const ctrlAPressed = useKeyPress(["LeftControl+A", "RightControl+A"]); 34 | 35 | useEffect(() => { 36 | print(`Space pressed: ${spacePressed}`); 37 | }, [spacePressed]); 38 | 39 | useEffect(() => { 40 | print(`Ctrl+A pressed: ${ctrlAPressed}`); 41 | }, [ctrlAPressed]); 42 | 43 | return undefined!; 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /src/use-key-press/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-key-press"; 2 | -------------------------------------------------------------------------------- /src/use-key-press/use-key-press.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useKeyPress } from "./use-key-press"; 5 | 6 | export = () => { 7 | it("should return a boolean", () => { 8 | const { result, unmount } = renderHook(() => useKeyPress(["W", "B"])); 9 | expect(result.current).to.be.a("boolean"); 10 | expect(result.current).to.equal(false); 11 | unmount(); 12 | }); 13 | 14 | // itFOCUS("should return true when pressed", () => { 15 | // let value = false; 16 | // const { result, unmount } = renderHook(() => { 17 | // value = useKeyPress(["W", "LeftShift+B"]) || value; 18 | // }); 19 | // task.wait(2); 20 | // unmount(); 21 | // expect(value).to.be.a("boolean"); 22 | // expect(value).to.equal(true); 23 | // }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/use-key-press/use-key-press.ts: -------------------------------------------------------------------------------- 1 | import { InferEnumNames, useEffect, useMemo, useState } from "@rbxts/react"; 2 | import { ContextActionService, HttpService, UserInputService } from "@rbxts/services"; 3 | import { useEventListener } from "../use-event-listener"; 4 | 5 | /** 6 | * A single key code name. 7 | */ 8 | export type KeyCode = InferEnumNames; 9 | 10 | /** 11 | * A single key code or a combination of key codes. 12 | */ 13 | export type KeyCodes = KeyCode | `${KeyCode}+${string}` | KeyCode[]; 14 | 15 | export interface KeyPressOptions { 16 | /** 17 | * Whether to bind a ContextActionService action to the key press. If `true`, 18 | * the action will be bound with the lifecycle of the component. The action will 19 | * sink the input, so the game will not process it. 20 | */ 21 | bindAction?: boolean; 22 | /** 23 | * The action priority to use when binding the action. Defaults to 24 | * `Enum.ContextActionPriority.High.Value`. 25 | */ 26 | actionPriority?: number; 27 | /** 28 | * The action name to use when binding the action. Defaults to a random name. 29 | */ 30 | actionName?: string; 31 | /** 32 | * The input types and key codes to listen for. Defaults to `Enum.UserInputType.Keyboard` 33 | * and `Enum.UserInputType.Gamepad1`. 34 | */ 35 | actionInputTypes?: (Enum.UserInputType | Enum.KeyCode)[]; 36 | } 37 | 38 | /** 39 | * Returns whether the passed key or shortcut is pressed. The hook expects one 40 | * or more key code, which can be: 41 | * 42 | * - A single key code `"W"` 43 | * - A combination of key codes `"W+Space"` 44 | * - An array of key codes `["W", "Space"]` 45 | * 46 | * Each combination is treated as its own shortcut. If passed more than one 47 | * combination, the hook will return `true` if any of the combinations are 48 | * pressed. 49 | * 50 | * @param keyCodeCombos The key code or combination of key codes to listen for. 51 | * @returns Whether the key or combination of keys is pressed. 52 | */ 53 | export function useKeyPress( 54 | keyCodeCombos: KeyCodes[], 55 | { 56 | bindAction = false, 57 | actionPriority = Enum.ContextActionPriority.High.Value, 58 | actionName = bindAction ? HttpService.GenerateGUID(false) : "", 59 | actionInputTypes = [Enum.UserInputType.Keyboard, Enum.UserInputType.Gamepad1], 60 | }: KeyPressOptions = {}, 61 | ) { 62 | const [pressed, setPressed] = useState(false); 63 | 64 | const keyCombos = useMemo(() => { 65 | return keyCodeCombos.map((keyCodes): KeyCode[] => { 66 | if (typeIs(keyCodes, "string")) { 67 | return keyCodes.split("+") as KeyCode[]; 68 | } else { 69 | return keyCodes; 70 | } 71 | }); 72 | }, keyCodeCombos); 73 | 74 | const keySet = useMemo(() => { 75 | const keySet = new Set(); 76 | 77 | for (const keys of keyCombos) { 78 | for (const key of keys) { 79 | keySet.add(key); 80 | } 81 | } 82 | 83 | return keySet; 84 | }, keyCombos); 85 | 86 | const keysDown = useMemo(() => { 87 | return new Set(); 88 | }, keyCodeCombos); 89 | 90 | const updatePressed = () => { 91 | setPressed(keyCombos.some((keys) => keys.every((key) => keysDown.has(key)))); 92 | }; 93 | 94 | useEventListener(UserInputService.InputBegan, (input, gameProcessed) => { 95 | if (!gameProcessed && keySet.has(input.KeyCode.Name)) { 96 | keysDown.add(input.KeyCode.Name); 97 | updatePressed(); 98 | } 99 | }); 100 | 101 | useEventListener(UserInputService.InputEnded, (input) => { 102 | if (keySet.has(input.KeyCode.Name)) { 103 | keysDown.delete(input.KeyCode.Name); 104 | updatePressed(); 105 | } 106 | }); 107 | 108 | useEffect(() => { 109 | // Prevents the game from processing the key 110 | if (!bindAction) { 111 | return; 112 | } 113 | 114 | ContextActionService.BindActionAtPriority( 115 | actionName, 116 | (_, state, input) => { 117 | const valid = keySet.has(input.KeyCode.Name); 118 | 119 | if (!valid) { 120 | return Enum.ContextActionResult.Pass; 121 | } 122 | 123 | if (state === Enum.UserInputState.Begin) { 124 | keysDown.add(input.KeyCode.Name); 125 | } else if (state === Enum.UserInputState.End) { 126 | keysDown.delete(input.KeyCode.Name); 127 | } 128 | 129 | updatePressed(); 130 | 131 | return Enum.ContextActionResult.Sink; 132 | }, 133 | false, 134 | actionPriority, 135 | ...actionInputTypes, 136 | ); 137 | 138 | return () => { 139 | ContextActionService.UnbindAction(actionName); 140 | }; 141 | }, [bindAction, actionName, actionPriority]); 142 | 143 | return pressed; 144 | } 145 | -------------------------------------------------------------------------------- /src/use-latest-callback/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useLatestCallback` 2 | 3 | ```ts 4 | function useLatestCallback(callback: T): T; 5 | ``` 6 | 7 | Returns a memoized callback that always points to the latest version of the callback. 8 | 9 | When passed a new callback, the return value will not change, but calling it will invoke the new callback. 10 | 11 | ### 📕 Parameters 12 | 13 | - `callback` - The callback to memoize. 14 | 15 | ### 📗 Returns 16 | 17 | - The memoized callback. 18 | 19 | ### 📘 Example 20 | 21 | ```tsx 22 | interface Props { 23 | onStep: () => void; 24 | } 25 | 26 | function Stepper({ onStep }: Props) { 27 | const onStepCallback = useLatestCallback(onStep); 28 | 29 | useEffect(() => { 30 | // Will always call the latest version of `onStep` 31 | const connection = RunService.RenderStepped.Connect(onStepCallback); 32 | 33 | return () => { 34 | connection.Disconnect(); 35 | }; 36 | }, []); 37 | 38 | return undefined!; 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /src/use-latest-callback/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-latest-callback"; 2 | -------------------------------------------------------------------------------- /src/use-latest-callback/use-latest-callback.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useLatestCallback } from "./use-latest-callback"; 5 | 6 | export = () => { 7 | it("should memoize the callback on mount", () => { 8 | const callback = () => {}; 9 | const { result } = renderHook(() => useLatestCallback(callback)); 10 | expect(result.current).to.be.a("function"); 11 | expect(result.current).never.to.equal(callback); 12 | }); 13 | 14 | it("should return memoized callback after unrelated rerender", () => { 15 | const { result, rerender } = renderHook(() => useLatestCallback(() => {})); 16 | const memoizedCallback = result.current; 17 | rerender(); 18 | expect(result.current).to.equal(memoizedCallback); 19 | }); 20 | 21 | it("should return memoized callback after passed callback changes", () => { 22 | const { result, rerender } = renderHook(({ callback }) => useLatestCallback(callback), { 23 | initialProps: { callback: () => {} }, 24 | }); 25 | const memoizedCallback = result.current; 26 | rerender({ callback: () => {} }); 27 | expect(result.current).to.equal(memoizedCallback); 28 | rerender({ callback: () => {} }); 29 | expect(result.current).to.equal(memoizedCallback); 30 | }); 31 | 32 | it("should memoize new callbacks", () => { 33 | const { result, rerender } = renderHook(({ callback }) => useLatestCallback(callback), { 34 | initialProps: { 35 | callback: (a: number, b: number) => a + b, 36 | }, 37 | }); 38 | const memoizedCallback = result.current; 39 | expect(memoizedCallback(1, 2)).to.equal(3); 40 | rerender({ callback: (a: number, b: number) => a - b }); 41 | expect(memoizedCallback(1, 2)).to.equal(-1); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/use-latest-callback/use-latest-callback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "@rbxts/react"; 2 | 3 | /** 4 | * Returns a memoized callback that wraps the latest version of the input 5 | * callback. 6 | * @param callback The callback to memoize. 7 | * @returns The memoized callback. 8 | */ 9 | export function useLatestCallback(callback: T): T { 10 | const callbackRef = useRef(callback); 11 | callbackRef.current = callback; 12 | 13 | return useCallback((...args: unknown[]) => { 14 | return callbackRef.current(...args); 15 | }, []) as T; 16 | } 17 | -------------------------------------------------------------------------------- /src/use-latest/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useLatest` 2 | 3 | ```ts 4 | function useLatest(value: T, isEqual?: (previous?: T, current: T) => boolean): { current: T }; 5 | ``` 6 | 7 | Returns a ref object whose `current` property points to the latest version of the value. 8 | 9 | It takes an optional equality function to determine whether the values are equal. If false, the value will be updated. 10 | 11 | ### 📕 Parameters 12 | 13 | - `value` - The value to wrap in a ref. 14 | - `isEqual?` - An equality function. Defaults to a strict equality check (`===`). 15 | 16 | ### 📗 Returns 17 | 18 | - A ref object. 19 | 20 | ### 📘 Example 21 | 22 | ```tsx 23 | function Counter() { 24 | const [value, setValue] = useState(0); 25 | const latestValue = useLatest(value); 26 | 27 | useEventListener(RunService.Heartbeat, () => { 28 | print(latestValue.current); 29 | }); 30 | 31 | return ( 32 | setValue(value + 1), 36 | }} 37 | /> 38 | ); 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /src/use-latest/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-latest"; 2 | -------------------------------------------------------------------------------- /src/use-latest/use-latest.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useLatest } from "./use-latest"; 5 | 6 | export = () => { 7 | it("should return a mutable ref", () => { 8 | const { result } = renderHook(() => useLatest(0)); 9 | expect(result.current.current).to.equal(0); 10 | }); 11 | 12 | it("should update the ref when the value changes", () => { 13 | const { result, rerender } = renderHook((props: { value: number }) => useLatest(props.value), { 14 | initialProps: { value: 0 }, 15 | }); 16 | 17 | expect(result.current.current).to.equal(0); 18 | rerender({ value: 1 }); 19 | expect(result.current.current).to.equal(1); 20 | }); 21 | 22 | it("should receive a function that determines whether the value should be updated", () => { 23 | const value0 = { value: 0 }; 24 | const value1 = { value: 0 }; 25 | const value2 = { value: 1 }; 26 | 27 | const { result, rerender } = renderHook(({ state }) => useLatest(state, (a, b) => a?.value === b.value), { 28 | initialProps: { state: value0 }, 29 | }); 30 | 31 | expect(result.current.current).to.equal(value0); 32 | rerender({ state: value1 }); 33 | expect(result.current.current).to.equal(value0); 34 | rerender({ state: value2 }); 35 | expect(result.current.current).to.equal(value2); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/use-latest/use-latest.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "@rbxts/react"; 2 | import { Predicate, isStrictEqual } from "../use-previous"; 3 | 4 | /** 5 | * Returns a mutable ref that points to the latest value of the input. 6 | * 7 | * Takes an optional `predicate` function as the second argument that receives 8 | * the previous and current value. If the predicate returns `false`, the values 9 | * are not equal, and the previous value is updated. 10 | * 11 | * @param value The value to track. 12 | * @returns A mutable reference to the value. 13 | */ 14 | export function useLatest(value: T, predicate: Predicate = isStrictEqual) { 15 | const ref = useRef(value); 16 | 17 | useMemo(() => { 18 | if (!predicate(ref.current, value)) { 19 | ref.current = value; 20 | } 21 | }, [value]); 22 | 23 | return ref; 24 | } 25 | -------------------------------------------------------------------------------- /src/use-lifetime/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useLifetime` 2 | 3 | ```ts 4 | function useLifetime(dependencies?: unknown[]): Binding; 5 | ``` 6 | 7 | Returns the amount of time that has passed since the component mounted. The binding is updated every frame on Heartbeat. 8 | 9 | If dependencies are provided, the binding will reset to `0` whenever any of the dependencies change. 10 | 11 | Useful for mapping time to procedural animations and other time-based effects. 12 | 13 | ### 📕 Parameters 14 | 15 | - `dependencies` - An optional array of dependencies. If provided, the binding will reset to `0` whenever any of the dependencies change. 16 | 17 | ### 📗 Returns 18 | 19 | - A binding that will update with the amount of time that has passed since the component mounted. 20 | 21 | ### 📘 Example 22 | 23 | ```tsx 24 | function Blink() { 25 | const lifetime = useLifetime(); 26 | const transparency = lifetime.map((t) => math.sin(t) * 0.5 + 0.5); 27 | 28 | return ; 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /src/use-lifetime/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-lifetime"; 2 | -------------------------------------------------------------------------------- /src/use-lifetime/use-lifetime.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useLifetime } from "./use-lifetime"; 5 | 6 | export = () => { 7 | it("should return the lifetime of the component", () => { 8 | const { result, unmount } = renderHook(() => useLifetime()); 9 | 10 | expect(result.current.getValue()).to.equal(0); 11 | 12 | const timePassed = task.wait(0.1); 13 | expect(result.current.getValue()).to.be.near(timePassed, 0.05); 14 | 15 | unmount(); 16 | }); 17 | 18 | it("should reset when dependencies change", () => { 19 | const { result, rerender, unmount } = renderHook((props: { value: number }) => useLifetime([props.value]), { 20 | initialProps: { value: 0 }, 21 | }); 22 | 23 | expect(result.current.getValue()).to.equal(0); 24 | 25 | const timePassed = task.wait(0.1); 26 | rerender({ value: 0 }); 27 | expect(result.current.getValue()).to.be.near(timePassed, 0.05); 28 | 29 | rerender({ value: 1 }); 30 | expect(result.current.getValue()).to.equal(0); 31 | 32 | unmount(); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/use-lifetime/use-lifetime.ts: -------------------------------------------------------------------------------- 1 | import { useBinding, useEffect } from "@rbxts/react"; 2 | import { RunService } from "@rbxts/services"; 3 | import { useEventListener } from "../use-event-listener"; 4 | 5 | /** 6 | * Returns the lifetime of the component in seconds. Updates every frame on 7 | * the Heartbeat event. 8 | * 9 | * If the dependency array is provided, the lifetime timer will reset when 10 | * any of the dependencies change. 11 | * 12 | * @param dependencies An optional array of dependencies to reset the timer. 13 | * @returns A binding of the component's lifetime. 14 | */ 15 | export function useLifetime(dependencies: unknown[] = []) { 16 | const [lifetime, setLifetime] = useBinding(0); 17 | 18 | useEventListener(RunService.Heartbeat, (deltaTime) => { 19 | setLifetime(lifetime.getValue() + deltaTime); 20 | }); 21 | 22 | useEffect(() => { 23 | setLifetime(0); 24 | }, dependencies); 25 | 26 | return lifetime; 27 | } 28 | -------------------------------------------------------------------------------- /src/use-motion/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useMotion` 2 | 3 | ```ts 4 | function useMotion(initialValue: T): LuaTuple<[value: Binding, motor: Motion]> 5 | ``` 6 | 7 | Creates a memoized Motion object set to the given initial value. Returns a binding that updates with the Motion, along with the Motion object. 8 | 9 | ### 📕 Parameters 10 | 11 | - `initialValue` - The initial value of the motor. 12 | 13 | ### 📗 Returns 14 | 15 | - A binding for the motor's value. 16 | - The motion object. See the [Ripple Repo](https://github.com/littensy/ripple) for the docs. 17 | 18 | ### 📘 Example 19 | 20 | A button that fades in and out when hovered. 21 | 22 | ```tsx 23 | function Button() { 24 | const [hover, hoverMotor] = useMotion(0); 25 | 26 | return ( 27 | hoverMotor.spring(1, config.spring.stiff), 30 | MouseLeave: () => hoverMotor.spring(0, config.spring.stiff), 31 | }} 32 | Size={new UDim2(0, 100, 0, 100)} 33 | BackgroundTransparency={hover.map((t) => lerp(0.8, 0.5, t))} 34 | /> 35 | ); 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /src/use-motion/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-motion"; 2 | -------------------------------------------------------------------------------- /src/use-motion/use-motion.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useMotion } from "./use-motion"; 5 | 6 | export = () => { 7 | it("should return a motor", () => { 8 | const { result, unmount } = renderHook(() => { 9 | const [value, motion] = useMotion(0); 10 | return { value, motion }; 11 | }); 12 | 13 | expect(result.current.value.getValue()).to.be.a("number"); 14 | expect(result.current.value.getValue()).to.equal(0); 15 | expect(result.current.motion).to.be.a("table"); 16 | 17 | unmount(); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/use-motion/use-motion.ts: -------------------------------------------------------------------------------- 1 | import type { Binding } from "@rbxts/react"; 2 | import { useBinding, useEffect, useMemo } from "@rbxts/react"; 3 | import type { Motion, MotionGoal } from "@rbxts/ripple"; 4 | import { createMotion } from "@rbxts/ripple"; 5 | import { RunService } from "@rbxts/services"; 6 | 7 | const callbacks = new Set<(dt: number) => void>(); 8 | let connection: RBXScriptConnection | undefined; 9 | 10 | function connect(callback: (dt: number) => void) { 11 | callbacks.add(callback); 12 | 13 | if (!connection) { 14 | connection = RunService.Heartbeat.Connect((dt) => { 15 | for (const callback of callbacks) { 16 | callback(dt); 17 | } 18 | }); 19 | } 20 | } 21 | 22 | function disconnect(callback: (dt: number) => void) { 23 | callbacks.delete(callback); 24 | 25 | if (callbacks.isEmpty()) { 26 | connection?.Disconnect(); 27 | connection = undefined; 28 | } 29 | } 30 | 31 | export function useMotion(initialValue: number): LuaTuple<[Binding, Motion]>; 32 | export function useMotion(initialValue: T): LuaTuple<[Binding, Motion]>; 33 | export function useMotion(initialValue: T) { 34 | const motion = useMemo(() => { 35 | return createMotion(initialValue); 36 | }, []); 37 | 38 | const [binding, setValue] = useBinding(initialValue); 39 | 40 | useEffect(() => { 41 | const callback = (delta: number) => { 42 | const value = motion.step(delta); 43 | 44 | if (value !== binding.getValue()) { 45 | setValue(value); 46 | } 47 | }; 48 | 49 | connect(callback); 50 | 51 | return () => disconnect(callback); 52 | }, []); 53 | 54 | return $tuple(binding, motion); 55 | } 56 | -------------------------------------------------------------------------------- /src/use-mount-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useMountEffect` 2 | 3 | ```ts 4 | function useMountEffect(callback: () => void): void; 5 | ``` 6 | 7 | Runs a callback when the component mounts. 8 | 9 | ### 📕 Parameters 10 | 11 | - `callback` - The callback to run on mount. 12 | 13 | ### 📗 Returns 14 | 15 | - `void` 16 | 17 | ### 📘 Example 18 | 19 | ```tsx 20 | function MountLogger() { 21 | useMountEffect(() => { 22 | print("Mounted"); 23 | }); 24 | 25 | return ; 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /src/use-mount-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-mount-effect"; 2 | -------------------------------------------------------------------------------- /src/use-mount-effect/use-mount-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useMountEffect } from "./use-mount-effect"; 5 | 6 | export = () => { 7 | it("should run callback on mount", () => { 8 | let renders = 0; 9 | let mounted = false; 10 | 11 | const { rerender } = renderHook(() => { 12 | useMountEffect(() => { 13 | mounted = !mounted; 14 | }); 15 | 16 | renders += 1; 17 | }); 18 | 19 | expect(renders).to.equal(1); 20 | expect(mounted).to.equal(true); 21 | 22 | rerender(); 23 | expect(renders).to.equal(2); 24 | expect(mounted).to.equal(true); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/use-mount-effect/use-mount-effect.ts: -------------------------------------------------------------------------------- 1 | import { EffectCallback, useEffect } from "@rbxts/react"; 2 | 3 | /** 4 | * Runs a callback when the component is mounted. 5 | * @param callback The callback to run. 6 | */ 7 | export function useMountEffect(callback: EffectCallback) { 8 | useEffect(callback, []); 9 | } 10 | -------------------------------------------------------------------------------- /src/use-mouse/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useMouse` 2 | 3 | ```ts 4 | function useMouse(listener?: (mouse: Vector2) => void): Binding; 5 | ``` 6 | 7 | Returns a binding to the position of the mouse. 8 | 9 | If a listener is provided, it will be called when the mouse moves and once on mount. 10 | 11 | ### 📕 Parameters 12 | 13 | - `listener` - An optional listener to be called when the mouse moves. 14 | 15 | ### 📗 Returns 16 | 17 | - The position of the mouse in pixels. 18 | 19 | ### 📘 Example 20 | 21 | ```tsx 22 | function MouseTracker() { 23 | const mouse = useMouse(); 24 | 25 | return ( 26 | UDim2.fromOffset(p.X, p.Y))} 30 | /> 31 | ); 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /src/use-mouse/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-mouse"; 2 | -------------------------------------------------------------------------------- /src/use-mouse/use-mouse.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { UserInputService } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useMouse } from "./use-mouse"; 6 | 7 | export = () => { 8 | it("should return a binding to the mouse position", () => { 9 | const { result, unmount } = renderHook(() => useMouse()); 10 | 11 | expect(result.current).to.be.a("table"); 12 | expect(result.current.getValue()).to.be.a("userdata"); 13 | expect(result.current.getValue()).to.equal(UserInputService.GetMouseLocation()); 14 | unmount(); 15 | }); 16 | 17 | it("should receive an optional listener", () => { 18 | let mouse = new Vector2(-1, -1); 19 | const { unmount } = renderHook(() => useMouse((m) => (mouse = m))); 20 | 21 | expect(mouse).never.to.equal(new Vector2(-1, -1)); 22 | expect(mouse).to.equal(UserInputService.GetMouseLocation()); 23 | unmount(); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/use-mouse/use-mouse.ts: -------------------------------------------------------------------------------- 1 | import { Binding, useBinding, useMemo } from "@rbxts/react"; 2 | import { UserInputService } from "@rbxts/services"; 3 | import { useEventListener } from "../use-event-listener"; 4 | import { useMountEffect } from "../use-mount-effect"; 5 | 6 | /** 7 | * Returns a binding to the mouse position. 8 | * @param listener Optional listener to be called when the mouse position changes. 9 | * @returns A binding to mouse position. 10 | */ 11 | export function useMouse(listener?: (mouse: Vector2) => void): Binding { 12 | const [mouse, setMouse] = useBinding(Vector2.one); 13 | 14 | useEventListener(UserInputService.InputChanged, (input) => { 15 | if ( 16 | input.UserInputType === Enum.UserInputType.MouseMovement || 17 | input.UserInputType === Enum.UserInputType.Touch 18 | ) { 19 | setMouse(UserInputService.GetMouseLocation()); 20 | listener?.(UserInputService.GetMouseLocation()); 21 | } 22 | }); 23 | 24 | useMemo(() => { 25 | setMouse(UserInputService.GetMouseLocation()); 26 | }, []); 27 | 28 | useMountEffect(() => { 29 | listener?.(mouse.getValue()); 30 | }); 31 | 32 | return mouse; 33 | } 34 | -------------------------------------------------------------------------------- /src/use-previous/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `usePrevious` 2 | 3 | ```tsx 4 | function usePrevious(value: T, isEqual?: (previous?: T, current: T) => boolean): T | undefined; 5 | ``` 6 | 7 | Returns a reference to the value from the previous render, or `undefined` on the first render. 8 | 9 | It takes an optional equality function to determine whether the values are equal. If false, the value will be updated. 10 | 11 | ### 📕 Parameters 12 | 13 | - `value` - The value to track. 14 | - `isEqual?` - An equality function. Defaults to a strict equality check (`===`). 15 | 16 | ### 📗 Returns 17 | 18 | - The value from the previous render, or `undefined` on the first render. 19 | 20 | ### 📘 Example 21 | 22 | ```tsx 23 | function ShowPrevious({ value }: Props) { 24 | const previousValue = usePrevious(value); 25 | 26 | return ; 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /src/use-previous/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-previous"; 2 | -------------------------------------------------------------------------------- /src/use-previous/use-previous.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { usePrevious } from "./use-previous"; 5 | 6 | export = () => { 7 | it("should return undefined on the first render", () => { 8 | const { result } = renderHook(() => usePrevious(0)); 9 | expect(result.current).to.equal(undefined); 10 | }); 11 | 12 | it("should return the previous value on rerender", () => { 13 | const { result, rerender } = renderHook(({ state }) => usePrevious(state), { 14 | initialProps: { state: 0 }, 15 | }); 16 | 17 | expect(result.current).to.equal(undefined); 18 | 19 | rerender({ state: 1 }); 20 | expect(result.current).to.equal(0); 21 | 22 | rerender({ state: 2 }); 23 | expect(result.current).to.equal(1); 24 | 25 | rerender({ state: 3 }); 26 | expect(result.current).to.equal(2); 27 | }); 28 | 29 | it("should return the correct value despite being undefined", () => { 30 | const { result, rerender } = renderHook(({ state }) => usePrevious(state), { 31 | initialProps: { state: undefined as number | undefined }, 32 | }); 33 | 34 | expect(result.current).to.equal(undefined); 35 | 36 | rerender({ state: undefined }); 37 | expect(result.current).to.equal(undefined); 38 | 39 | rerender({ state: 0 }); 40 | expect(result.current).to.equal(undefined); 41 | 42 | rerender({ state: undefined }); 43 | expect(result.current).to.equal(0); 44 | }); 45 | 46 | it("should not return passed value after unrelated rerender", () => { 47 | const { result, rerender } = renderHook(({ state }) => usePrevious(state), { 48 | initialProps: { state: 0 }, 49 | }); 50 | 51 | expect(result.current).to.equal(undefined); 52 | rerender(); 53 | expect(result.current).never.to.equal(0); 54 | expect(result.current).to.equal(undefined); 55 | }); 56 | 57 | it("should receive a function that determines whether the value should be updated", () => { 58 | const value0 = { value: 0 }; 59 | const value1 = { value: 1 }; 60 | const value2 = { value: 2 }; 61 | 62 | const { result, rerender } = renderHook(({ state }) => usePrevious(state, (a, b) => a?.value === b.value), { 63 | initialProps: { state: value0 }, 64 | }); 65 | 66 | expect(result.current).to.equal(undefined); 67 | 68 | rerender({ state: { ...value0 } }); 69 | expect(result.current).to.equal(undefined); 70 | 71 | rerender({ state: value1 }); 72 | expect(result.current).to.equal(value0); 73 | 74 | rerender({ state: value2 }); 75 | expect(result.current).to.equal(value1); 76 | 77 | rerender({ state: value1 }); 78 | expect(result.current).to.equal(value2); 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /src/use-previous/use-previous.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "@rbxts/react"; 2 | 3 | export type Predicate = (previous: T | undefined, current: T) => boolean; 4 | 5 | export const isStrictEqual = (a: unknown, b: unknown) => a === b; 6 | 7 | /** 8 | * Returns the most recent value from the previous render. Returns `undefined` 9 | * on the first render. 10 | * 11 | * Takes an optional `predicate` function as the second argument that receives 12 | * the previous and current value. If the predicate returns `false`, the values 13 | * are not equal, and the previous value is updated. 14 | * 15 | * @param value The value to return on the next render if it changes. 16 | * @param predicate Optional function to determine whether the value changed. 17 | * Defaults to a strict equality check (`===`). 18 | * @returns The previous value. 19 | */ 20 | export function usePrevious(value: T, predicate: Predicate = isStrictEqual): T | undefined { 21 | const previousRef = useRef(); 22 | const currentRef = useRef(); 23 | 24 | useMemo(() => { 25 | if (!predicate(currentRef.current, value)) { 26 | previousRef.current = currentRef.current; 27 | currentRef.current = value; 28 | } 29 | }, [value]); 30 | 31 | return previousRef.current; 32 | } 33 | -------------------------------------------------------------------------------- /src/use-spring/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useSpring` 2 | 3 | ```ts 4 | function useSpring(goal: T | Binding, options?: SpringOptions): Binding 5 | ``` 6 | 7 | Applies spring animations to the given value, and updates the goal with the latest value on every re-render. Returns a binding that updates with the Motion. 8 | 9 | ### 📕 Parameters 10 | 11 | - `goal` - The goal of the motor. 12 | - `options` - Options for the spring (or a spring config). 13 | 14 | ### 📗 Returns 15 | 16 | - A binding of the motor's value. 17 | 18 | ### 📘 Example 19 | 20 | A button changes to the colour of its props. 21 | 22 | ```tsx 23 | function Button({ color }: Props) { 24 | const color = useSpring(color, config.spring.stiff); 25 | 26 | return ( 27 | 28 | ); 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /src/use-spring/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-spring"; 2 | -------------------------------------------------------------------------------- /src/use-spring/use-spring.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useSpring } from "./use-spring"; 5 | 6 | export = () => { 7 | it("should return a binding", () => { 8 | const { result, unmount } = renderHook(() => useSpring(0)); 9 | 10 | expect(result.current.getValue()).to.be.a("number"); 11 | expect(result.current.getValue()).to.equal(0); 12 | 13 | unmount(); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/use-spring/use-spring.ts: -------------------------------------------------------------------------------- 1 | import type { Binding } from "@rbxts/react"; 2 | import { useRef } from "@rbxts/react"; 3 | import type { MotionGoal, SpringOptions } from "@rbxts/ripple"; 4 | import { useMotion } from "../use-motion"; 5 | import { getBindingValue } from "../utils/binding"; 6 | import { useEventListener } from "../use-event-listener"; 7 | import { RunService } from "@rbxts/services"; 8 | 9 | export function useSpring(goal: number | Binding, options?: SpringOptions): Binding; 10 | export function useSpring(goal: T | Binding, options?: SpringOptions): Binding; 11 | export function useSpring(goal: MotionGoal | Binding, options?: SpringOptions) { 12 | const [binding, motion] = useMotion(getBindingValue(goal)); 13 | const previousValue = useRef(getBindingValue(goal)); 14 | 15 | useEventListener(RunService.Heartbeat, () => { 16 | const currentValue = getBindingValue(goal); 17 | 18 | if (currentValue !== previousValue.current) { 19 | previousValue.current = currentValue; 20 | motion.spring(currentValue, options); 21 | } 22 | }); 23 | 24 | return binding; 25 | } 26 | -------------------------------------------------------------------------------- /src/use-tagged/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useTagged` 2 | 3 | ```ts 4 | function useTagged(tag: string): readonly T[]; 5 | ``` 6 | 7 | Tracks and returns all instances assigned to the given [`CollectionService`](https://create.roblox.com/docs/reference/engine/classes/CollectionService) tag. Re-renders the component when tagged instances are added or removed from the data model. 8 | 9 | ### 📕 Parameters 10 | 11 | - `tag` - The [tag](https://create.roblox.com/docs/studio/properties#instance-tags) to filter instances for. 12 | 13 | ### 📗 Returns 14 | 15 | - A list of instances with the given tag. 16 | 17 | ### 📘 Example 18 | 19 | Get all instances with the tag `"Zombie"`: 20 | 21 | ```tsx 22 | function ZombieHealth() { 23 | const zombies = useTagged("Zombie"); 24 | 25 | return ( 26 | <> 27 | {zombies.map((zombie) => ( 28 | 29 | ))} 30 | 31 | ); 32 | } 33 | ``` 34 | 35 | Get all instances with the tag `"Zombie"` and cast them to a custom `ZombieModel` type: 36 | 37 | ```tsx 38 | interface ZombieModel extends Model { 39 | Health: NumberValue; 40 | } 41 | 42 | function ZombieHealth() { 43 | const zombies = useTagged("Zombie"); 44 | 45 | return ( 46 | <> 47 | {zombies.map((zombie) => ( 48 | 49 | ))} 50 | 51 | ); 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /src/use-tagged/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-tagged"; 2 | -------------------------------------------------------------------------------- /src/use-tagged/use-tagged.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Lighting } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useTagged } from "./use-tagged"; 6 | 7 | const TEST_TAG = "TEST_TAG"; 8 | 9 | export = () => { 10 | const testInstances: Instance[] = []; 11 | 12 | function addTaggedInstance(instanceType: T, tag: string) { 13 | const newInstance = new Instance(instanceType); 14 | newInstance.AddTag(tag); 15 | newInstance.Parent = Lighting; 16 | testInstances.push(newInstance); 17 | task.wait(); 18 | return newInstance; 19 | } 20 | 21 | afterEach(() => { 22 | for (const instance of testInstances) { 23 | instance.Destroy(); 24 | } 25 | }); 26 | 27 | it("should include existing instances", () => { 28 | const addedInstance = addTaggedInstance("Model", TEST_TAG); 29 | 30 | const { result, unmount } = renderHook(() => useTagged(TEST_TAG)); 31 | 32 | expect(result.current.size()).to.equal(1); 33 | expect(result.current[0]).to.equal(addedInstance); 34 | unmount(); 35 | }); 36 | 37 | it("should add new instances", () => { 38 | const { result, unmount } = renderHook(() => useTagged(TEST_TAG)); 39 | 40 | const addedInstance = addTaggedInstance("Model", TEST_TAG); 41 | 42 | expect(result.current.size()).to.equal(1); 43 | expect(result.current[0]).to.equal(addedInstance); 44 | unmount(); 45 | }); 46 | 47 | it("should delete removed instances", () => { 48 | const { result, unmount } = renderHook(() => useTagged(TEST_TAG)); 49 | 50 | const addedInstance = addTaggedInstance("Model", TEST_TAG); 51 | 52 | expect(result.current.size()).to.equal(1); 53 | expect(result.current[0]).to.equal(addedInstance); 54 | 55 | addedInstance.Destroy(); 56 | task.wait(); 57 | 58 | expect(result.current.size()).to.equal(0); 59 | 60 | unmount(); 61 | }); 62 | 63 | it("should ONLY include instances of the provided tag", () => { 64 | const { result, unmount } = renderHook(() => useTagged(TEST_TAG)); 65 | 66 | const addedInstance = addTaggedInstance("Model", TEST_TAG); 67 | addTaggedInstance("Model", `NOT_${TEST_TAG}`); 68 | 69 | expect(result.current.size()).to.equal(1); 70 | expect(result.current[0]).to.equal(addedInstance); 71 | unmount(); 72 | }); 73 | 74 | it("should not duplicate instances", () => { 75 | const addedInstance = addTaggedInstance("Model", TEST_TAG); 76 | const { result, unmount } = renderHook(() => useTagged(TEST_TAG)); 77 | 78 | expect(result.current.size()).to.equal(1); 79 | expect(result.current[0]).to.equal(addedInstance); 80 | unmount(); 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /src/use-tagged/use-tagged.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "@rbxts/react"; 2 | import { useEventListener } from "../use-event-listener"; 3 | import { CollectionService } from "@rbxts/services"; 4 | 5 | /** 6 | * Wrapper around `CollectionService` that provides a list of `Instance` under the given `tag`. 7 | * 8 | * This list is updated as `Instance` are added and removed. You can also cast the `Instance` to the expected type. 9 | * 10 | * ```ts 11 | * const zombies = useTagged("zombie"); 12 | * ``` 13 | * 14 | * @param tag The `CollectionService` tag name to filter against. 15 | * @returns A stateful list of `Instance` matching the provided `tag`. 16 | * @template T An optional subtype of `Instance` to cast the tagged children to. 17 | */ 18 | export function useTagged(tag: string): readonly T[] { 19 | const [instances, setInstances] = useState(() => CollectionService.GetTagged(tag)); 20 | 21 | useEventListener(CollectionService.GetInstanceAddedSignal(tag), (instance) => { 22 | setInstances((instances) => { 23 | const nextInstances = table.clone(instances); 24 | nextInstances.push(instance); 25 | return nextInstances; 26 | }); 27 | }); 28 | 29 | useEventListener(CollectionService.GetInstanceRemovedSignal(tag), (instance) => { 30 | setInstances((instances) => { 31 | const nextInstances = table.clone(instances); 32 | const index = nextInstances.indexOf(instance); 33 | nextInstances.remove(index); 34 | return nextInstances; 35 | }); 36 | }); 37 | 38 | return instances as T[]; 39 | } 40 | -------------------------------------------------------------------------------- /src/use-throttle-callback/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useThrottleCallback` 2 | 3 | ```ts 4 | function useThrottleCallback(callback: T, options?: UseThrottleOptions): UseDebounceResult; 5 | ``` 6 | 7 | Creates a throttled function that only invokes `callback` at most once per every `wait` seconds. 8 | 9 | The callback is invoked with the last arguments provided to the throttled function. Subsequent calls to the throttled function return the result of the last `callback` invocation. 10 | 11 | See [lodash.throttle](https://lodash.com/docs/4.17.15#throttle) for the function this hook is based on. 12 | 13 | ### 📕 Parameters 14 | 15 | - `callback` - The function to throttle. 16 | - `options` - The options object. 17 | - `wait` - The number of seconds to throttle. Defaults to `0`. 18 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `true`. 19 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 20 | 21 | ### 📗 Returns 22 | 23 | - A `UseDebounceResult` object. 24 | - `run` - The throttled function. 25 | - `cancel` - Cancels any pending invocation. 26 | - `flush` - Immediately invokes a pending invocation. 27 | - `pending` - Whether there is a pending invocation. 28 | 29 | ### 📘 Example 30 | 31 | Throttle viewport size updates to once per second. 32 | 33 | ```tsx 34 | function ViewportProvider() { 35 | const camera = useCamera(); 36 | const [viewport, setViewport] = useState(camera.ViewportSize); 37 | 38 | const throttled = useThrottleCallback((size: Vector2) => { 39 | setViewport(size); 40 | }, 1); 41 | 42 | useEventListener(camera.GetPropertyChangedSignal("ViewportSize"), () => { 43 | throttled.run(camera.ViewportSize); 44 | }); 45 | 46 | return ; 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /src/use-throttle-callback/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-throttle-callback"; 2 | -------------------------------------------------------------------------------- /src/use-throttle-callback/use-throttle-callback.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useThrottleCallback } from "./use-throttle-callback"; 5 | 6 | export = () => { 7 | it("should return run, cancel, and flush", () => { 8 | let count = 0; 9 | const { result } = renderHook(() => useThrottleCallback((amount: number) => (count += amount), { wait: 0.06 })); 10 | 11 | result.current.run(1); 12 | expect(count).to.equal(1); 13 | result.current.run(1); 14 | result.current.run(1); 15 | result.current.run(1); 16 | expect(count).to.equal(1); 17 | 18 | task.wait(0.04); 19 | result.current.run(2); 20 | expect(count).to.equal(1); 21 | 22 | task.wait(0.03); 23 | result.current.run(2); 24 | expect(count).to.equal(3); 25 | result.current.run(3); 26 | result.current.run(3); 27 | 28 | task.wait(0.07); 29 | expect(count).to.equal(6); 30 | result.current.run(1); 31 | result.current.run(4); 32 | result.current.cancel(); 33 | 34 | task.wait(0.07); 35 | expect(count).to.equal(7); 36 | result.current.run(1); 37 | result.current.run(1); 38 | expect(count).to.equal(8); 39 | result.current.flush(); 40 | expect(count).to.equal(9); 41 | 42 | task.wait(0.07); 43 | expect(count).to.equal(9); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/use-throttle-callback/use-throttle-callback.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "@rbxts/react"; 2 | import { Debounced, ThrottleOptions, throttle } from "@rbxts/set-timeout"; 3 | import { UseDebounceResult } from "../use-debounce-callback"; 4 | import { useLatest } from "../use-latest"; 5 | import { useUnmountEffect } from "../use-unmount-effect"; 6 | 7 | export interface UseThrottleOptions extends ThrottleOptions { 8 | /** 9 | * The amount of time to wait before the first call. 10 | */ 11 | wait?: number; 12 | } 13 | 14 | /** 15 | * Creates a throttled function that only invokes `callback` at most once per 16 | * every `wait` seconds. The `callback` is invoked with the most recent arguments 17 | * provided to the throttled function. Subsequent calls to the throttled function 18 | * return the result of the last `callback` invocation. 19 | * 20 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 21 | * for details over the differences between `throttle` and `debounce`. 22 | * 23 | * @param callback The function to throttle. 24 | * @param options The options object. 25 | * @returns The new throttled function. 26 | */ 27 | export function useThrottleCallback( 28 | callback: T, 29 | options: UseThrottleOptions = {}, 30 | ): UseDebounceResult { 31 | const callbackRef = useLatest(callback); 32 | 33 | const throttled = useMemo(() => { 34 | return throttle( 35 | (...args: unknown[]) => { 36 | return callbackRef.current(...args); 37 | }, 38 | options.wait, 39 | options, 40 | ); 41 | }, []) as Debounced; 42 | 43 | useUnmountEffect(() => { 44 | throttled.cancel(); 45 | }); 46 | 47 | return { 48 | run: throttled, 49 | cancel: throttled.cancel, 50 | flush: throttled.flush, 51 | pending: throttled.pending, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/use-throttle-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useThrottleEffect` 2 | 3 | ```ts 4 | function useThrottleEffect( 5 | effect: () => (() => void) | void, 6 | dependencies?: unknown[], 7 | options?: UseThrottleOptions, 8 | ): void; 9 | ``` 10 | 11 | Creates a throttled effect that only runs at most once per every `wait` seconds. 12 | 13 | See [lodash.throttle](https://lodash.com/docs/4.17.15#throttle) for the function this hook is based on. 14 | 15 | ### 📕 Parameters 16 | 17 | - `effect` - The effect to throttle. 18 | - `dependencies` - The dependencies array. 19 | - `options` - The options object. 20 | - `wait` - The number of seconds to throttle. Defaults to `0`. 21 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `true`. 22 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 23 | 24 | ### 📗 Returns 25 | 26 | - `void` 27 | 28 | ### 📘 Example 29 | 30 | Throttle viewport size updates to once per second. 31 | 32 | ```tsx 33 | function ResizeLogger() { 34 | const camera = useCamera(); 35 | const [viewport, setViewport] = useState(camera.ViewportSize); 36 | 37 | useEventListener(camera.GetPropertyChangedSignal("ViewportSize"), () => { 38 | setViewport(camera.ViewportSize); 39 | }); 40 | 41 | useThrottleEffect( 42 | () => { 43 | print("Viewport size updated:", viewport); 44 | }, 45 | [viewport], 46 | { time: 1 }, 47 | ); 48 | 49 | return ; 50 | } 51 | ``` 52 | -------------------------------------------------------------------------------- /src/use-throttle-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-throttle-effect"; 2 | -------------------------------------------------------------------------------- /src/use-throttle-effect/use-throttle-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useThrottleEffect } from "./use-throttle-effect"; 5 | 6 | export = () => { 7 | it("should throttle the effect", () => { 8 | let count = 0; 9 | const { rerender, unmount } = renderHook( 10 | ({ input }) => 11 | useThrottleEffect( 12 | () => { 13 | count += 1; 14 | }, 15 | [input], 16 | { wait: 0.06 }, 17 | ), 18 | { initialProps: { input: 0 } }, 19 | ); 20 | 21 | rerender({ input: 1 }); 22 | expect(count).to.equal(1); 23 | rerender({ input: 1 }); 24 | rerender({ input: 1 }); 25 | rerender({ input: 1 }); 26 | expect(count).to.equal(1); 27 | 28 | task.wait(0.04); 29 | rerender({ input: 2 }); 30 | expect(count).to.equal(1); 31 | 32 | task.wait(0.03); 33 | rerender({ input: 2 }); 34 | expect(count).to.equal(2); 35 | rerender({ input: 3 }); 36 | rerender({ input: 3 }); 37 | 38 | task.wait(0.065); 39 | expect(count).to.equal(3); 40 | unmount(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/use-throttle-effect/use-throttle-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { UseThrottleOptions, useThrottleCallback } from "../use-throttle-callback"; 3 | import { useUpdate } from "../use-update"; 4 | import { useUpdateEffect } from "../use-update-effect"; 5 | 6 | /** 7 | * Creates a throttled effect that only runs at most once per every `wait` 8 | * seconds. 9 | * 10 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 11 | * for details over the differences between `debounce` and `throttle`. 12 | * 13 | * @param effect The effect to throttle. 14 | * @param dependencies The dependencies array. 15 | * @param options The options object. 16 | */ 17 | export function useThrottleEffect( 18 | effect: () => (() => void) | void, 19 | dependencies?: unknown[], 20 | options?: UseThrottleOptions, 21 | ) { 22 | const update = useUpdate(); 23 | 24 | const { run } = useThrottleCallback(update, options); 25 | 26 | useEffect(() => { 27 | return run(); 28 | }, dependencies); 29 | 30 | useUpdateEffect(effect, [update]); 31 | } 32 | -------------------------------------------------------------------------------- /src/use-throttle-state/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useThrottleState` 2 | 3 | ```ts 4 | function useThrottleState(initialState: T, options?: UseThrottleOptions): T; 5 | ``` 6 | 7 | Creates a throttled state that only updates at most once per every `wait` seconds. Set to the most recently passed `state` after each interval. 8 | 9 | See [lodash.throttle](https://lodash.com/docs/4.17.15#throttle) for the function this hook is based on. 10 | 11 | ### 📕 Parameters 12 | 13 | - `initialState` - The initial state. 14 | - `options` - The options object. 15 | - `wait` - The number of seconds to throttle. Defaults to `0`. 16 | - `leading` - Specify invoking on the leading edge of the timeout. Defaults to `true`. 17 | - `trailing` - Specify invoking on the trailing edge of the timeout. Defaults to `true`. 18 | 19 | ### 📗 Returns 20 | 21 | - The throttled state. 22 | - A function to update the throttled state. 23 | 24 | ### 📘 Example 25 | 26 | Throttle viewport size updates to once per second. 27 | 28 | ```tsx 29 | function ViewportProvider() { 30 | const camera = useCamera(); 31 | const [viewport, setViewport] = useThrottleState(camera.ViewportSize, { time: 1 }); 32 | 33 | useEventListener(camera.GetPropertyChangedSignal("ViewportSize"), () => { 34 | setViewport(camera.ViewportSize); 35 | }); 36 | 37 | return ; 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /src/use-throttle-state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-throttle-state"; 2 | -------------------------------------------------------------------------------- /src/use-throttle-state/use-throttle-state.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useThrottleState } from "./use-throttle-state"; 5 | 6 | export = () => { 7 | it("should throttle the state", () => { 8 | const { result, unmount } = renderHook(() => { 9 | const [state, setState] = useThrottleState(0, { wait: 0.06 }); 10 | return { state, setState }; 11 | }); 12 | 13 | result.current.setState(1); 14 | expect(result.current.state).to.equal(1); 15 | result.current.setState(1); 16 | result.current.setState(1); 17 | result.current.setState(1); 18 | expect(result.current.state).to.equal(1); 19 | 20 | task.wait(0.04); 21 | result.current.setState(2); 22 | expect(result.current.state).to.equal(1); 23 | 24 | task.wait(0.03); 25 | result.current.setState(2); 26 | expect(result.current.state).to.equal(2); 27 | result.current.setState(3); 28 | result.current.setState(3); 29 | 30 | task.wait(0.065); 31 | expect(result.current.state).to.equal(3); 32 | unmount(); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /src/use-throttle-state/use-throttle-state.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from "@rbxts/react"; 2 | import { UseThrottleOptions, useThrottleCallback } from "../use-throttle-callback"; 3 | 4 | /** 5 | * Creates a throttled state that only updates at most once per every `wait` 6 | * seconds. Set to the most recently passed `state` after each interval. 7 | * 8 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 9 | * for details over the differences between `debounce` and `throttle`. 10 | * 11 | * @param value The value to throttle. 12 | * @param options The options object. 13 | * @returns The throttled value. 14 | */ 15 | export function useThrottleState( 16 | initialState: T, 17 | options?: UseThrottleOptions, 18 | ): LuaTuple<[T, Dispatch>]> { 19 | const [state, setState] = useState(initialState); 20 | 21 | return $tuple(state, useThrottleCallback(setState, options).run); 22 | } 23 | -------------------------------------------------------------------------------- /src/use-timeout/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useTimeout` 2 | 3 | ```ts 4 | function useTimeout(callback: () => void, delay?: number): () => void; 5 | ``` 6 | 7 | Sets a timeout that runs the callback after `delay` seconds. Returns a function that clears the timeout. 8 | 9 | If `delay` is `undefined`, the timeout is cleared. If the delay updates, the timeout is reset. 10 | 11 | The callback is memoized for you and will not reset the timeout if it changes. 12 | 13 | ### 📕 Parameters 14 | 15 | - `callback` - The function to run after `delay` seconds. 16 | - `delay` - The number of seconds to wait before calling `callback`. If `undefined`, the timeout is cleared. 17 | 18 | ### 📗 Returns 19 | 20 | - A function that clears the timeout. 21 | 22 | ### 📘 Example 23 | 24 | A text label that displays the number `1` after one second. 25 | 26 | ```tsx 27 | function CountOne() { 28 | const [count, setCount] = useState(0); 29 | 30 | useTimeout(() => { 31 | setCount(count + 1); 32 | }, 1); 33 | 34 | return ; 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /src/use-timeout/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-timeout"; 2 | -------------------------------------------------------------------------------- /src/use-timeout/use-timeout.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useTimeout } from "./use-timeout"; 5 | 6 | export = () => { 7 | it("should run the callback after the delay", () => { 8 | let count = 0; 9 | const { unmount } = renderHook(() => { 10 | useTimeout(() => count++, 0.03); 11 | }); 12 | 13 | expect(count).to.equal(0); 14 | 15 | task.wait(0.04); 16 | expect(count).to.equal(1); 17 | 18 | task.wait(0.04); 19 | expect(count).to.equal(1); 20 | unmount(); 21 | }); 22 | 23 | it("should clear when delay is undefined", () => { 24 | let count = 0; 25 | const { rerender, unmount } = renderHook( 26 | ({ delay }) => { 27 | useTimeout(() => count++, delay); 28 | }, 29 | { initialProps: { delay: 0.06 as number | undefined } }, 30 | ); 31 | 32 | expect(count).to.equal(0); 33 | 34 | task.wait(0.01); 35 | expect(count).to.equal(0); 36 | rerender({ delay: undefined }); 37 | 38 | task.wait(0.07); 39 | expect(count).to.equal(0); 40 | unmount(); 41 | }); 42 | 43 | it("should clear on unmount", () => { 44 | let count = 0; 45 | const { unmount } = renderHook(() => { 46 | useTimeout(() => count++, 0.06); 47 | }); 48 | 49 | expect(count).to.equal(0); 50 | 51 | task.wait(0.01); 52 | expect(count).to.equal(0); 53 | unmount(); 54 | 55 | task.wait(0.06); 56 | expect(count).to.equal(0); 57 | }); 58 | 59 | it("should reset when delay updates", () => { 60 | let count = 0; 61 | const { rerender, unmount } = renderHook( 62 | ({ delay }) => { 63 | useTimeout(() => count++, delay); 64 | }, 65 | { initialProps: { delay: 0.06 as number | undefined } }, 66 | ); 67 | 68 | expect(count).to.equal(0); 69 | 70 | task.wait(0.01); 71 | expect(count).to.equal(0); 72 | rerender({ delay: 0.06 }); 73 | 74 | task.wait(0.01); 75 | expect(count).to.equal(0); 76 | rerender({ delay: 0.12 }); 77 | 78 | task.wait(0.07); 79 | expect(count).to.equal(0); 80 | task.wait(0.07); 81 | expect(count).to.equal(1); 82 | unmount(); 83 | }); 84 | 85 | it("should return a clear function", () => { 86 | let count = 0; 87 | const { result, unmount } = renderHook(() => { 88 | return useTimeout(() => count++, 0.06); 89 | }); 90 | 91 | expect(count).to.equal(0); 92 | 93 | task.wait(0.01); 94 | expect(count).to.equal(0); 95 | result.current(); 96 | 97 | task.wait(0.07); 98 | expect(count).to.equal(0); 99 | unmount(); 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /src/use-timeout/use-timeout.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "@rbxts/react"; 2 | import { setTimeout } from "@rbxts/set-timeout"; 3 | import { useLatestCallback } from "../use-latest-callback"; 4 | 5 | /** 6 | * Sets a timeout that runs the callback function after `delay` seconds. If 7 | * `delay` is `undefined`, the timeout is cleared. If the delay changes, the 8 | * timeout is reset. 9 | * @param callback The callback function to run. 10 | * @param delay The delay in seconds before the timeout. 11 | * @returns A function that clears the timeout. 12 | */ 13 | export function useTimeout(callback: () => void, delay?: number) { 14 | const callbackMemo = useLatestCallback(callback); 15 | const cancel = useRef<() => void>(); 16 | 17 | const clear = useCallback(() => { 18 | cancel.current?.(); 19 | }, []); 20 | 21 | useEffect(() => { 22 | if (delay === undefined) { 23 | return; 24 | } 25 | cancel.current = setTimeout(callbackMemo, delay); 26 | return clear; 27 | }, [delay]); 28 | 29 | return clear; 30 | } 31 | -------------------------------------------------------------------------------- /src/use-timer/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useTimer` 2 | 3 | ```ts 4 | function useTimer(initialValue?: number): Timer; 5 | ``` 6 | 7 | Returns a timer whose `value` field is a binding that will update every frame on Heartbeat. The timer's `value` field will start at `initialValue` if provided, or `0` otherwise. 8 | 9 | By default, the timer will start when the component mounts. If you want to start or stop the timer later, you can use the `start` and `stop` methods. 10 | 11 | ### 📕 Parameters 12 | 13 | - `initialValue` - An optional initial value for the timer. Defaults to `0`. 14 | 15 | ### 📗 Returns 16 | 17 | - A `Timer` object: 18 | - `value` - A binding that will update every frame. 19 | - `start()` - Starts the timer if it is not already running. 20 | - `stop()` - Stops the timer if it is running. 21 | - `reset()` - Resets the value to `0`. 22 | - `set(value)` - Sets the value to a new value. 23 | 24 | ### 📘 Example 25 | 26 | ```tsx 27 | function Blink() { 28 | const timer = useTimer(); 29 | const transparency = timer.value.map((t) => math.sin(t) * 0.5 + 0.5); 30 | 31 | return ( 32 | timer.reset(), 36 | }} 37 | /> 38 | ); 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /src/use-timer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-timer"; 2 | -------------------------------------------------------------------------------- /src/use-timer/use-timer.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useTimer } from "./use-timer"; 5 | 6 | export = () => { 7 | it("should start the timer on mount", () => { 8 | const { result, unmount } = renderHook(() => useTimer()); 9 | 10 | expect(result.current.value.getValue()).to.equal(0); 11 | 12 | const timePassed = task.wait(0.2); 13 | expect(result.current.value.getValue()).to.be.near(timePassed, 0.08); 14 | 15 | unmount(); 16 | }); 17 | 18 | it("should return functions to start and stop the timer", () => { 19 | const { result, unmount } = renderHook(() => useTimer()); 20 | 21 | expect(result.current.value.getValue()).to.equal(0); 22 | 23 | const timePassed = task.wait(0.2); 24 | const timerValue = result.current.value.getValue(); 25 | expect(timerValue).to.be.near(timePassed, 0.08); 26 | 27 | result.current.stop(); 28 | 29 | task.wait(0.2); 30 | expect(result.current.value.getValue()).to.equal(timerValue); 31 | 32 | result.current.start(); 33 | 34 | const timePassedAfterStart = task.wait(0.2); 35 | expect(result.current.value.getValue()).to.be.near(timePassed + timePassedAfterStart, 0.08); 36 | 37 | unmount(); 38 | }); 39 | 40 | it("should return a function to set the timer", () => { 41 | const { result, unmount } = renderHook(() => useTimer()); 42 | 43 | expect(result.current.value.getValue()).to.equal(0); 44 | 45 | const timePassed = task.wait(0.2); 46 | expect(result.current.value.getValue()).to.be.near(timePassed, 0.08); 47 | 48 | result.current.reset(); 49 | expect(result.current.value.getValue()).to.equal(0); 50 | 51 | result.current.set(1); 52 | expect(result.current.value.getValue()).to.equal(1); 53 | 54 | const timePassedAfterSet = task.wait(0.2); 55 | expect(result.current.value.getValue()).to.be.near(timePassedAfterSet + 1, 0.08); 56 | 57 | unmount(); 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/use-timer/use-timer.ts: -------------------------------------------------------------------------------- 1 | import { Binding, useBinding, useCallback, useRef } from "@rbxts/react"; 2 | import { RunService } from "@rbxts/services"; 3 | import { useEventListener } from "../use-event-listener"; 4 | 5 | export interface Timer { 6 | /** 7 | * A binding that represents the current value of the timer. 8 | */ 9 | readonly value: Binding; 10 | /** 11 | * Starts the timer if it is not already running. 12 | */ 13 | readonly start: () => void; 14 | /** 15 | * Pauses the timer if it is running. 16 | */ 17 | readonly stop: () => void; 18 | /** 19 | * Resets the timer to 0. 20 | */ 21 | readonly reset: () => void; 22 | /** 23 | * Sets the timer to a specific value. 24 | * @param value The value to set the timer to. 25 | */ 26 | readonly set: (value: number) => void; 27 | } 28 | 29 | /** 30 | * Creates a timer that can be used to track a value over time. 31 | * @param initialValue The initial value of the timer. 32 | * @returns A timer object. 33 | */ 34 | export function useTimer(initialValue = 0): Timer { 35 | const [value, setValue] = useBinding(initialValue); 36 | 37 | const started = useRef(true); 38 | 39 | useEventListener(RunService.Heartbeat, (deltaTime) => { 40 | if (started.current) { 41 | setValue(value.getValue() + deltaTime); 42 | } 43 | }); 44 | 45 | const start = useCallback(() => { 46 | started.current = true; 47 | }, []); 48 | 49 | const stop = useCallback(() => { 50 | started.current = false; 51 | }, []); 52 | 53 | const reset = useCallback(() => { 54 | setValue(0); 55 | }, []); 56 | 57 | const set = useCallback((value: number) => { 58 | setValue(value); 59 | }, []); 60 | 61 | return { value, start, stop, reset, set }; 62 | } 63 | -------------------------------------------------------------------------------- /src/use-unmount-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useUnmountEffect` 2 | 3 | ```ts 4 | function useUnmountEffect(callback: () => void): void; 5 | ``` 6 | 7 | Calls the callback when the component unmounts. This is useful for cleaning up side effects. 8 | 9 | ### 📕 Parameters 10 | 11 | - `callback` - The callback to call when the component unmounts. 12 | 13 | ### 📗 Returns 14 | 15 | - `void` 16 | 17 | ### 📘 Example 18 | 19 | ```tsx 20 | function UnmountLogger() { 21 | useUnmountEffect(() => { 22 | print("Unmounting..."); 23 | }); 24 | 25 | return ; 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /src/use-unmount-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-unmount-effect"; 2 | -------------------------------------------------------------------------------- /src/use-unmount-effect/use-unmount-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useUnmountEffect } from "./use-unmount-effect"; 5 | 6 | export = () => { 7 | it("should call when component unmounts", () => { 8 | let called = false; 9 | const { unmount } = renderHook(() => useUnmountEffect(() => (called = true))); 10 | expect(called).to.equal(false); 11 | unmount(); 12 | expect(called).to.equal(true); 13 | }); 14 | 15 | it("should not call on rerender", () => { 16 | let called = false; 17 | const { rerender } = renderHook(() => useUnmountEffect(() => (called = true))); 18 | expect(called).to.equal(false); 19 | rerender(); 20 | expect(called).to.equal(false); 21 | }); 22 | 23 | it("should call the last callback on unmount", () => { 24 | let called = 0; 25 | const { rerender, unmount } = renderHook((callback: () => void) => useUnmountEffect(callback), { 26 | initialProps: () => (called = 0), 27 | }); 28 | rerender(() => (called += 1)); 29 | unmount(); 30 | expect(called).to.equal(1); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/use-unmount-effect/use-unmount-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "@rbxts/react"; 2 | import { useLatest } from "../use-latest"; 3 | 4 | /** 5 | * Calls the callback when the component unmounts. 6 | * @param callback The callback to call. 7 | */ 8 | export function useUnmountEffect(callback: () => void) { 9 | const callbackRef = useLatest(callback); 10 | 11 | useEffect(() => { 12 | return () => { 13 | callbackRef.current(); 14 | }; 15 | }, []); 16 | } 17 | -------------------------------------------------------------------------------- /src/use-update-effect/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useUpdateEffect` 2 | 3 | ```ts 4 | function useUpdateEffect(callback: () => void | (() => void), dependencies?: DependencyList): void; 5 | ``` 6 | 7 | Runs a callback when the component updates. Does not run on mount. 8 | 9 | ### 📕 Parameters 10 | 11 | - `callback` - The callback to run on update. Supports returning a cleanup function. 12 | - `dependencies` - Optional dependencies to watch for changes. 13 | 14 | ### 📗 Returns 15 | 16 | - `void` 17 | 18 | ### 📘 Example 19 | 20 | ```tsx 21 | function RenderLogger() { 22 | const [state, setState] = useState(0); 23 | 24 | useUpdateEffect(() => { 25 | print("Updated"); 26 | }); 27 | 28 | return ( 29 | setState(state + 1), 33 | }} 34 | /> 35 | ); 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /src/use-update-effect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-update-effect"; 2 | -------------------------------------------------------------------------------- /src/use-update-effect/use-update-effect.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { renderHook } from "../utils/testez"; 4 | import { useUpdateEffect } from "./use-update-effect"; 5 | 6 | export = () => { 7 | it("should call effect on update only", () => { 8 | let renders = 0; 9 | const { rerender } = renderHook(() => 10 | useUpdateEffect(() => { 11 | renders += 1; 12 | }), 13 | ); 14 | 15 | expect(renders).to.equal(0); 16 | 17 | rerender(); 18 | expect(renders).to.equal(1); 19 | }); 20 | 21 | it("should call effect with dependencies", () => { 22 | let renders = 0; 23 | const { rerender } = renderHook((value) => 24 | useUpdateEffect(() => { 25 | renders += 1; 26 | }, [value]), 27 | ); 28 | 29 | expect(renders).to.equal(0); 30 | 31 | rerender("test"); 32 | expect(renders).to.equal(1); 33 | 34 | rerender("test"); 35 | expect(renders).to.equal(1); 36 | 37 | rerender("test2"); 38 | expect(renders).to.equal(2); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/use-update-effect/use-update-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "@rbxts/react"; 2 | 3 | /** 4 | * Runs a callback when the component is re-rendered. Does not run on the 5 | * first render. 6 | * @param effect The callback to run. 7 | * @param dependencies The dependencies to watch for changes. 8 | */ 9 | export function useUpdateEffect(effect: () => (() => void) | void, dependencies?: unknown[]) { 10 | const isMounted = useRef(false); 11 | 12 | useEffect(() => { 13 | if (isMounted.current) { 14 | return effect(); 15 | } else { 16 | isMounted.current = true; 17 | } 18 | }, dependencies); 19 | } 20 | -------------------------------------------------------------------------------- /src/use-update/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useUpdate` 2 | 3 | ```ts 4 | function useUpdate(): () => void; 5 | ``` 6 | 7 | Returns a function that can be called to force an update of the component. 8 | 9 | The function returned by `useUpdate` is recreated when it causes an update, making it useful to track re-renders caused by this hook. 10 | 11 | ### 📕 Parameters 12 | 13 | ### 📗 Returns 14 | 15 | - A function that can be called to force an update of the component. 16 | 17 | ### 📘 Example 18 | 19 | ```tsx 20 | function RenderLogger() { 21 | const update = useUpdate(); 22 | 23 | useEffect(() => { 24 | return setInterval(() => { 25 | update(); 26 | }, 1); 27 | }, []); 28 | 29 | useEffect(() => { 30 | print("Rendered because of useUpdate"); 31 | }, [update]); 32 | 33 | print("Rendered"); 34 | 35 | return ; 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /src/use-update/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-update"; 2 | -------------------------------------------------------------------------------- /src/use-update/use-update.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useEffect } from "@rbxts/react"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useUpdate } from "./use-update"; 6 | 7 | export = () => { 8 | it("should cause a rerender", () => { 9 | let renders = 0; 10 | 11 | renderHook(() => { 12 | const rerender = useUpdate(); 13 | 14 | useEffect(() => { 15 | rerender(); 16 | }, []); 17 | 18 | renders += 1; 19 | }); 20 | 21 | expect(renders).to.equal(2); 22 | }); 23 | 24 | it("should return a new function on each update", () => { 25 | let rerender = () => {}; 26 | let previousRerender = () => {}; 27 | 28 | renderHook(() => { 29 | previousRerender = rerender; 30 | rerender = useUpdate(); 31 | }); 32 | 33 | expect(rerender).never.to.equal(previousRerender); 34 | 35 | rerender(); 36 | expect(rerender).never.to.equal(previousRerender); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/use-update/use-update.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "@rbxts/react"; 2 | 3 | /** 4 | * Returns a function that can be used to force a component to update. The 5 | * function is recreated on the next render if called. This makes it useful as 6 | * a dependency for other hooks. 7 | * @returns A function that forces a rerender. 8 | */ 9 | export function useUpdate() { 10 | const [state, setState] = useState({}); 11 | 12 | return useCallback(() => { 13 | setState({}); 14 | }, [state]); 15 | } 16 | -------------------------------------------------------------------------------- /src/use-viewport/README.md: -------------------------------------------------------------------------------- 1 | ## 🪝 `useViewport` 2 | 3 | ```ts 4 | function useViewport(listener?: (viewport: Vector2) => void): Binding; 5 | ``` 6 | 7 | Returns a binding to the size of the viewport. 8 | 9 | If a listener is provided, it will be called when the viewport changes and once on mount. 10 | 11 | ### 📕 Parameters 12 | 13 | - `listener` - An optional listener to be called when the viewport size changes. 14 | 15 | ### 📗 Returns 16 | 17 | - The size of the viewport in pixels. 18 | 19 | ### 📘 Example 20 | 21 | ```tsx 22 | function TextSize() { 23 | const viewport = useViewport(); 24 | const textSize = viewport.map((size) => { 25 | return math.min(size.X / 1920, size.Y / 1080) * 14; 26 | }); 27 | 28 | return ; 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /src/use-viewport/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-viewport"; 2 | -------------------------------------------------------------------------------- /src/use-viewport/use-viewport.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Workspace } from "@rbxts/services"; 4 | import { renderHook } from "../utils/testez"; 5 | import { useViewport } from "./use-viewport"; 6 | 7 | export = () => { 8 | it("should return a binding to the viewport size", () => { 9 | const { result, unmount } = renderHook(() => useViewport()); 10 | 11 | expect(result.current).to.be.a("table"); 12 | expect(result.current.getValue()).to.be.a("userdata"); 13 | expect(result.current.getValue()).to.equal(Workspace.CurrentCamera!.ViewportSize); 14 | unmount(); 15 | }); 16 | 17 | it("should receive an optional listener", () => { 18 | let viewport = new Vector2(-1, -1); 19 | const { unmount } = renderHook(() => useViewport((v) => (viewport = v))); 20 | 21 | expect(viewport).never.to.equal(new Vector2(-1, -1)); 22 | expect(viewport).to.equal(Workspace.CurrentCamera!.ViewportSize); 23 | unmount(); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/use-viewport/use-viewport.ts: -------------------------------------------------------------------------------- 1 | import { useBinding, useEffect } from "@rbxts/react"; 2 | import { useCamera } from "../use-camera"; 3 | 4 | /** 5 | * Returns the current viewport size of the camera. 6 | * @param listener Optional listener to be called when the viewport changes. 7 | * @returns A binding to the viewport size. 8 | */ 9 | export function useViewport(listener?: (viewport: Vector2) => void) { 10 | const camera = useCamera(); 11 | const [viewport, setViewport] = useBinding(camera.ViewportSize); 12 | 13 | useEffect(() => { 14 | const connection = camera.GetPropertyChangedSignal("ViewportSize").Connect(() => { 15 | setViewport(camera.ViewportSize); 16 | listener?.(camera.ViewportSize); 17 | }); 18 | 19 | setViewport(camera.ViewportSize); 20 | listener?.(camera.ViewportSize); 21 | 22 | return () => { 23 | connection.Disconnect(); 24 | }; 25 | }, [camera]); 26 | 27 | return viewport; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/binding.ts: -------------------------------------------------------------------------------- 1 | import { Binding, createBinding, joinBindings } from "@rbxts/react"; 2 | import { lerp } from "./math"; 3 | 4 | export interface BindingApi { 5 | subscribe: (callback: (newValue: T) => void) => () => void; 6 | update: (newValue: T) => void; 7 | getValue: () => T; 8 | } 9 | 10 | export interface Lerpable { 11 | Lerp: (this: T, to: T, alpha: number) => T; 12 | } 13 | 14 | export type BindingOrValue = Binding | T; 15 | 16 | type Bindable = Binding | NonNullable; 17 | 18 | type ComposeBindings = { 19 | [K in keyof T]: T[K] extends Bindable ? U : T[K]; 20 | }; 21 | 22 | type BindingCombiner = (...values: ComposeBindings) => U; 23 | 24 | /** 25 | * Returns whether the given value is a binding. 26 | * @param value The value to check. 27 | * @returns Whether the value is a binding. 28 | */ 29 | export function isBinding(value: T | Binding): value is Binding; 30 | export function isBinding(value: unknown): value is Binding; 31 | export function isBinding(value: unknown): value is Binding { 32 | return typeIs(value, "table") && "getValue" in value && "map" in value; 33 | } 34 | 35 | /** 36 | * Converts a value to a binding. If the given value is already a binding, it 37 | * will be returned as-is. 38 | * @param value The value to convert. 39 | * @returns The converted binding. 40 | */ 41 | export function toBinding(value: T | Binding): Binding { 42 | if (isBinding(value)) { 43 | return value; 44 | } else { 45 | const [result] = createBinding(value); 46 | return result; 47 | } 48 | } 49 | 50 | /** 51 | * Returns the value of a binding. If the given value is not a binding, it will 52 | * be returned as-is. 53 | * @param binding The binding to get the value of. 54 | * @returns The value of the binding. 55 | */ 56 | export function getBindingValue(binding: T | Binding): T { 57 | if (isBinding(binding)) { 58 | return binding.getValue(); 59 | } else { 60 | return binding; 61 | } 62 | } 63 | 64 | /** 65 | * Maps a binding to a new binding. If the given value is not a binding, it will 66 | * be passed to the mapper function and returned as a new binding. 67 | * @param binding The binding to map. 68 | * @param callback The mapper function. 69 | * @returns The mapped binding. 70 | */ 71 | export function mapBinding(binding: T | Binding, callback: (value: T) => U): Binding { 72 | if (isBinding(binding)) { 73 | return binding.map(callback); 74 | } else { 75 | const [result] = createBinding(callback(binding as T)); 76 | return result; 77 | } 78 | } 79 | 80 | /** 81 | * Joins a map of bindings into a single binding. If any of the given values 82 | * are not bindings, they will be wrapped in a new binding. 83 | * @param bindings The bindings to join. 84 | * @returns The joined binding. 85 | */ 86 | export function joinAnyBindings>>( 87 | bindings: T, 88 | ): Binding<{ [K in keyof T]: T[K] extends BindingOrValue ? U : T[K] }>; 89 | export function joinAnyBindings( 90 | bindings: readonly [...T], 91 | ): Binding<{ [K in keyof T]: T[K] extends BindingOrValue ? U : T[K] }>; 92 | export function joinAnyBindings(bindings: object): Binding { 93 | const bindingsToMap = {} as Record>; 94 | 95 | for (const [k, v] of pairs(bindings)) { 96 | bindingsToMap[k as keyof object] = toBinding(v); 97 | } 98 | 99 | return joinBindings(bindingsToMap); 100 | } 101 | 102 | /** 103 | * Gets the internal API of a binding. This is a hacky way to get access to the 104 | * `BindingInternalApi` object of a binding, which is not exposed by React. 105 | * @param binding The binding to get the internal API of. 106 | * @returns The binding's API. 107 | */ 108 | export function getBindingApi(binding: Binding) { 109 | for (const [key, value] of pairs(binding)) { 110 | const name = tostring(key); 111 | 112 | if (name === "Symbol(BindingImpl)" || name.sub(1, 12) === "RoactBinding") { 113 | return value as unknown as BindingApi; 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Returns a binding that lerps between two values using the given binding as 120 | * the alpha. 121 | * @param binding The binding to use as the alpha. 122 | * @param from The value to lerp from. 123 | * @param to The value to lerp to. 124 | * @returns A binding that lerps between two values. 125 | */ 126 | export function lerpBinding>( 127 | binding: Binding | number, 128 | from: T, 129 | to: T, 130 | ): Binding { 131 | return mapBinding(binding, (alpha) => { 132 | if (typeIs(from, "number")) { 133 | return lerp(from, to as number, alpha); 134 | } else { 135 | return from.Lerp(to, alpha); 136 | } 137 | }); 138 | } 139 | 140 | /** 141 | * Composes multiple bindings or values together into a single binding. 142 | * Calls the combiner function with the values of the bindings when any 143 | * of the bindings change. 144 | * @param ...bindings A list of bindings or values. 145 | * @param combiner The function that maps the bindings to a new value. 146 | * @returns A binding that returns the result of the combiner. 147 | */ 148 | export function composeBindings(...bindings: [...T, BindingCombiner]): Binding; 149 | 150 | export function composeBindings(...values: [...Bindable[], BindingCombiner]): Binding { 151 | const combiner = values.pop() as BindingCombiner; 152 | const bindings = values.map(toBinding); 153 | 154 | return joinBindings(bindings).map((bindings) => combiner(...bindings)); 155 | } 156 | -------------------------------------------------------------------------------- /src/utils/hoarcekat.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "@rbxts/react-roblox"; 2 | import React, { StrictMode } from "@rbxts/react"; 3 | 4 | /** 5 | * Returns a function that can be used as a Hoarcekat story. This function will 6 | * mount the given component to the target instance and unmount it when the 7 | * story is unmounted. 8 | * @param TestComponent The component to mount. 9 | * @param options Optional options to pass to `withHookDetection`. 10 | * @returns A Hoarcekat story. 11 | */ 12 | export function hoarcekat(TestComponent: React.FunctionComponent) { 13 | return (target: Instance) => { 14 | const root = createRoot(target); 15 | 16 | root.render( 17 | 18 | 19 | , 20 | ); 21 | 22 | return () => { 23 | root.unmount(); 24 | }; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Linearly interpolates between two numbers. 3 | * @param a The first number. 4 | * @param b The second number. 5 | * @param alpha The alpha value to use. 6 | * @returns The interpolated number. 7 | */ 8 | export function lerp(a: number, b: number, alpha: number) { 9 | return a + (b - a) * alpha; 10 | } 11 | 12 | /** 13 | * Maps a value from one range to another. 14 | * @param value The value to map. 15 | * @param fromMin The minimum of the input range. 16 | * @param fromMax The maximum of the input range. 17 | * @param toMin The minimum of the output range. 18 | * @param toMax The maximum of the output range. 19 | * @returns The mapped value. 20 | */ 21 | export function map(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { 22 | return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; 23 | } 24 | 25 | /** 26 | * Multiplies transparency values together. Normally, multiplying transparency 27 | * values requires inverting them (to get opacity), multiplying them, and then 28 | * inverting them again. This function does that for you. 29 | * @param transparencies The transparencies to multiply. 30 | * @returns The multiplied transparency. 31 | */ 32 | export function blend(...transparencies: number[]) { 33 | let result = 1; 34 | 35 | for (const transparency of transparencies) { 36 | result *= 1 - transparency; 37 | } 38 | 39 | return 1 - result; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/shallow-equal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compares two objects to see if they are shallowly equal. 3 | * @param objectA The first object to compare. 4 | * @param objectB The second object to compare. 5 | * @returns Whether or not the two objects are shallowly equal. 6 | */ 7 | export function shallowEqual(objectA?: object, objectB?: object): boolean { 8 | if (objectA === objectB) { 9 | return true; 10 | } 11 | 12 | if (objectA === undefined || objectB === undefined) { 13 | return false; 14 | } 15 | 16 | for (const [key, value] of pairs(objectA)) { 17 | if (objectB[key as never] !== value) { 18 | return false; 19 | } 20 | } 21 | 22 | for (const [key, value] of pairs(objectB)) { 23 | if (objectA[key as never] !== value) { 24 | return false; 25 | } 26 | } 27 | 28 | return true; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/testez.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "@rbxts/react"; 2 | import { act, createLegacyRoot } from "@rbxts/react-roblox"; 3 | 4 | export interface RenderHookResult { 5 | /** 6 | * Triggers a re-render. The props will be passed to your renderHook callback. 7 | */ 8 | rerender: (props?: Props) => void; 9 | /** 10 | * A stable reference to the latest value returned by your renderHook callback. 11 | */ 12 | result: { 13 | /** 14 | * The value returned by your renderHook callback. 15 | */ 16 | current: Result; 17 | }; 18 | /** 19 | * Unmounts the test component. This is useful for when you need to test any 20 | * cleanup your useEffects have. 21 | */ 22 | unmount: () => void; 23 | } 24 | 25 | export interface RenderHookOptions { 26 | /** 27 | * The container to render into. Defaults to nil, which means the component 28 | * will not be mounted to a Roblox instance. 29 | */ 30 | container?: Instance; 31 | /** 32 | * The argument passed to the renderHook callback. Can be useful if you plan 33 | * to use the rerender utility to change the values passed to your hook. 34 | */ 35 | initialProps?: Props; 36 | } 37 | 38 | /** 39 | * Allows you to render a hook within a test React component without having to 40 | * create that component yourself. 41 | * @see https://github.com/testing-library/react-testing-library 42 | */ 43 | export function renderHook( 44 | render: (initialProps: Props) => Result, 45 | options: RenderHookOptions = {}, 46 | ): RenderHookResult { 47 | const result = { current: undefined as Result }; 48 | 49 | function TestComponent({ initialProps }: { initialProps?: Props }) { 50 | const previousProps = useRef(initialProps); 51 | const pendingResult = render(initialProps ?? previousProps.current ?? ({} as Props)); 52 | 53 | previousProps.current = initialProps; 54 | result.current = pendingResult; 55 | 56 | return undefined!; 57 | } 58 | 59 | const root = createLegacyRoot(options.container || new Instance("Folder")); 60 | 61 | act(() => { 62 | root.render(); 63 | }); 64 | 65 | const rerender = (props?: Props) => { 66 | act(() => { 67 | root.render(); 68 | }); 69 | }; 70 | 71 | const unmount = () => { 72 | act(() => root.unmount()); 73 | }; 74 | 75 | return { rerender, result, unmount }; 76 | } 77 | -------------------------------------------------------------------------------- /test.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "package-test", 3 | "globIgnorePaths": ["**/package.json", "**/tsconfig.json"], 4 | "tree": { 5 | "$className": "DataModel", 6 | "ReplicatedStorage": { 7 | "$className": "ReplicatedStorage", 8 | "rbxts_include": { 9 | "$path": "include", 10 | "node_modules": { 11 | "$className": "Folder", 12 | "@rbxts": { 13 | "$path": "node_modules/@rbxts" 14 | }, 15 | "@rbxts-js": { 16 | "$path": "node_modules/@rbxts-js" 17 | } 18 | } 19 | }, 20 | "TS": { 21 | "$path": "out" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /testez-companion.toml: -------------------------------------------------------------------------------- 1 | roots = ["ReplicatedStorage/TS"] 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // required 4 | "allowSyntheticDefaultImports": true, 5 | "downlevelIteration": true, 6 | "jsx": "react", 7 | "jsxFactory": "React.createElement", 8 | "jsxFragmentFactory": "React.Fragment", 9 | "module": "commonjs", 10 | "moduleResolution": "Node", 11 | "noLib": true, 12 | "resolveJsonModule": true, 13 | "experimentalDecorators": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "moduleDetection": "force", 16 | "strict": true, 17 | "target": "ESNext", 18 | "typeRoots": ["node_modules/@rbxts"], 19 | 20 | // configurable 21 | "rootDir": "src", 22 | "outDir": "out", 23 | "incremental": true, 24 | "tsBuildInfoFile": "out/tsconfig.tsbuildinfo", 25 | "declaration": true 26 | } 27 | } 28 | --------------------------------------------------------------------------------