├── .changeset
├── README.md
└── config.json
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── CHANGELOG.md
├── README.md
├── babel.config.cjs
├── package.json
├── renovate.json
├── rollup.config.js
├── src
├── createUseTransition.spec.tsx
├── createUseTransition.ts
└── main.ts
├── tsconfig.build-cjs.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "linked": [],
6 | "access": "restricted",
7 | "baseBranch": "main",
8 | "updateInternalDependencies": "patch",
9 | "ignore": []
10 | }
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | jobs:
5 | ci:
6 | name: CI
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - name: Cache node_modules
11 | id: cache-modules
12 | uses: actions/cache@v2
13 | with:
14 | path: node_modules
15 | key: 14.x-${{ runner.OS }}-build-${{ hashFiles('yarn.lock') }}
16 | - uses: actions/setup-node@v2
17 | with:
18 | node-version: "14.x"
19 | registry-url: "https://registry.npmjs.org"
20 | - name: Install dependencies
21 | if: steps.cache-modules.outputs.cache-hit != 'true'
22 | run: yarn
23 | - name: Test
24 | run: yarn test
25 | - name: Build
26 | run: yarn build
27 | - name: Type Check
28 | run: yarn type-check
29 | - name: Configure Git Credentials
30 | if: github.repository == 'n1ru4l/react-use-transition'
31 | run: |
32 | git config --global user.email "github-action@users.noreply.github.com"
33 | git config --global user.name "Github Action"
34 | - name: Publish
35 | if: github.repository == 'n1ru4l/react-use-transition'
36 | id: publish-step
37 | run: |
38 | cd dist
39 | yarn version --no-git-tag-version --prerelease --preid ${GITHUB_SHA::8}
40 | yarn publish --tag canary
41 | PACKAGE_VERSION=$(node -e "console.log(require('./package.json').version)")
42 | echo "::set-output name=PACKAGE_VERSION::$PACKAGE_VERSION"
43 | env:
44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45 | - name: Create commit comment
46 | uses: peter-evans/commit-comment@v1
47 | if: github.repository == 'n1ru4l/react-use-transition'
48 | with:
49 | body: |
50 | The changes have been published to npm.
51 | ```bash
52 | yarn add -D @n1ru4l/react-use-transition@${{ steps.publish-step.outputs.PACKAGE_VERSION }}
53 | ```
54 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout Repo
14 | uses: actions/checkout@master
15 | with:
16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
17 | fetch-depth: 0
18 |
19 | - name: Setup Node.js 14.x
20 | uses: actions/setup-node@master
21 | with:
22 | node-version: 14.x
23 |
24 | - name: Install Dependencies
25 | run: yarn
26 |
27 | - name: Build
28 | run: yarn build
29 |
30 | - name: Create Release Pull Request or Publish to npm
31 | id: changesets
32 | uses: changesets/action@master
33 | with:
34 | # This expects you to have a script called release which does a build for your packages and calls changeset publish
35 | publish: yarn release
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 | .parcel-cache
78 |
79 | # Next.js build output
80 | .next
81 | out
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and not Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
108 | # Stores VSCode versions used for testing VSCode extensions
109 | .vscode-test
110 |
111 | # yarn v2
112 | .yarn/cache
113 | .yarn/unplugged
114 | .yarn/build-state.yml
115 | .yarn/install-state.gz
116 | .pnp.*
117 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.defaultFormatter": "esbenp.prettier-vscode"
4 | }
5 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @n1ru4l/react-use-transition
2 |
3 | ## 0.4.3
4 |
5 | ### Patch Changes
6 |
7 | - 2f1a958: BREAKING: switch out tuple return types.
8 |
9 | ```diff
10 | - const [showLoadingIndicator, cachedData] = useTransition()
11 | + const [cachedData, showLoadingIndicator] = useTransition()
12 | ```
13 |
14 | ## 0.4.2
15 |
16 | ### Patch Changes
17 |
18 | - 001dc61: fix publish script
19 |
20 | ## 0.4.1
21 |
22 | ### Patch Changes
23 |
24 | - db294c0: Include README.md in published package.
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @n1ru4l/react-use-transition
2 |
3 | [](http://www.typescriptlang.org/)
4 | [](https://www.npmjs.com/package/@n1ru4l/react-use-transition)
5 | [](https://bundlephobia.com/result?p=@n1ru4l/react-use-transition)
6 | [](https://www.npmjs.com/package/@n1ru4l/react-use-transition)
7 | [](https://www.npmjs.com/package/@n1ru4l/react-use-transition)
8 |
9 | Suspense like transitions without experimental react features today. For any fetching library.
10 |
11 | ## Why?
12 |
13 | Ever experienced flashy transitions where content disappears after triggering navigation, a loading state shows up for like 10 milliseconds, disappears and a new page is rendered? Did you wonder why it was even necessary to show a loading page in the first place?
14 |
15 | That is exactly what this micro library tries to solve. Cache the previous result in case a transition occurs and only show some kind of loading indicator after a certain threshold (by default 300ms) has been reached without the new data arriving.
16 |
17 | The concept is taken from the experimental `React.useTransition` which is not stable as today and only available as an experimental build. This hook however works without React concurrent mode.
18 |
19 | Use it together with your favorite GraphQL client such as `relay` or `urql` or any other data fetching library.
20 |
21 | ## Install
22 |
23 | ```bash
24 | yarn install -E @n1ru4l/react-use-transition
25 | ```
26 |
27 | ## Usage
28 |
29 | [Check out the code sandbox](https://codesandbox.io/s/usetransition-85v3c?file=/src/index.js)
30 |
31 | ```tsx
32 | import * as React from "react";
33 | import { unstable_batchedUpdates } from "react-dom"; // or react-native (your react reconciler)
34 | import { createUseTransition } from "@n1ru4l/react-use-transition";
35 |
36 | import { useQuery } from "your-fetching-library-of-choice";
37 |
38 | const useTransition = createUseTransition(unstable_batchedUpdates);
39 |
40 | const DataFetchingComponent = ({ postId }) => {
41 | const { data, isLoading } = useQuery("/foo/:postId", { postId });
42 | const [cachedData, showLoadingIndicator] = useTransition(data, isLoading);
43 |
44 | return (
45 | <>
46 | {showLoadingIndicator ? : null}
47 | {cachedData ? (
48 | cachedData.error ? (
49 |
50 | ) : (
51 |
52 | )
53 | ) : null}
54 | >
55 | );
56 | };
57 | ```
58 |
--------------------------------------------------------------------------------
/babel.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "current" } }],
4 | "@babel/preset-typescript",
5 | "@babel/preset-react",
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@n1ru4l/react-use-transition",
3 | "version": "0.4.3",
4 | "repository": "https://github.com/n1ru4l/use-transition",
5 | "author": "n1ru4l",
6 | "license": "MIT",
7 | "devDependencies": {
8 | "@babel/core": "7.14.3",
9 | "@babel/preset-env": "7.14.2",
10 | "@babel/preset-react": "7.13.13",
11 | "@babel/preset-typescript": "7.13.0",
12 | "@changesets/cli": "2.16.0",
13 | "@rollup/plugin-typescript": "8.2.1",
14 | "@testing-library/react": "11.2.7",
15 | "@types/react": "17.0.6",
16 | "@types/react-dom": "17.0.5",
17 | "babel-jest": "26.6.3",
18 | "cpy-cli": "3.1.1",
19 | "cross-env": "7.0.3",
20 | "jest": "26.6.3",
21 | "prettier": "2.3.0",
22 | "react": "17.0.2",
23 | "react-dom": "17.0.2",
24 | "rimraf": "3.0.2",
25 | "rollup": "2.48.0",
26 | "tslib": "2.2.0",
27 | "typescript": "4.2.4"
28 | },
29 | "peerDependencies": {
30 | "react": "17.x.x"
31 | },
32 | "type": "module",
33 | "types": "./main.d.ts",
34 | "typescript": {
35 | "definition": "./main.d.ts"
36 | },
37 | "module": "./main.js",
38 | "main": "./cjs/main.js",
39 | "exports": {
40 | ".": {
41 | "import": "./main.js",
42 | "require": "./cjs/main.js"
43 | },
44 | "./package.json": "./package.json",
45 | "./": "./"
46 | },
47 | "scripts": {
48 | "build:cjs": "cross-env MODE=cjs rollup -c",
49 | "build:esm": "rollup -c",
50 | "build": "rimraf dist && yarn build:cjs && yarn build:esm && cpy README.md ./dist",
51 | "test": "jest",
52 | "type-check": "tsc",
53 | "release": "changeset publish"
54 | },
55 | "keywords": [
56 | "react",
57 | "hook",
58 | "transition",
59 | "query",
60 | "fetch",
61 | "suspense"
62 | ],
63 | "publishConfig": {
64 | "directory": "dist",
65 | "access": "public"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:base"],
4 | "postUpdateOptions": ["yarnDedupeFewer"],
5 | "packageRules": [
6 | {
7 | "groupName": "react",
8 | "packagePatterns": [
9 | "@types/react",
10 | "@types/react-dom",
11 | "react",
12 | "react-dom"
13 | ]
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import ts from "@rollup/plugin-typescript";
2 | import { promises as fs } from "fs";
3 |
4 | const isCJSBuild = process.env.MODE === "cjs";
5 |
6 | const commonjsPkgJSONPlugin = () => {
7 | return {
8 | name: "commonjsPkgJSONPlugin",
9 | writeBundle: async () => {
10 | if (isCJSBuild === true) {
11 | await fs.writeFile(
12 | "dist/cjs/package.json",
13 | JSON.stringify({
14 | type: "commonjs",
15 | })
16 | );
17 | } else {
18 | await fs.copyFile("package.json", "dist/package.json");
19 | }
20 | },
21 | };
22 | };
23 |
24 | export default {
25 | input: ["src/main.ts"],
26 | output: [
27 | {
28 | dir: isCJSBuild ? "dist/cjs" : "dist",
29 | format: isCJSBuild ? "cjs" : "esm",
30 | },
31 | ],
32 | plugins: [
33 | ts({
34 | tsconfig: isCJSBuild ? "tsconfig.build-cjs.json" : "tsconfig.build.json",
35 | }),
36 | commonjsPkgJSONPlugin(),
37 | ],
38 | external: ["react"],
39 | };
40 |
--------------------------------------------------------------------------------
/src/createUseTransition.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { createUseTransition } from "./createUseTransition";
3 | import { test, expect, beforeEach, jest } from "@jest/globals";
4 | import { render, act } from "@testing-library/react";
5 |
6 | const useTransition = createUseTransition(act);
7 |
8 | let state: ReturnType | null = null;
9 |
10 | beforeEach(() => {
11 | state?.unmount();
12 | });
13 |
14 | test("returns the current value for a non-loading object", () => {
15 | expect.assertions(2);
16 | const TestComponent = () => {
17 | const [data, isLoading] = useTransition({ hello: "hi" }, false);
18 | expect(isLoading).toEqual(false);
19 | expect(data).toEqual({ hello: "hi" });
20 | return null;
21 | };
22 | state = render();
23 | });
24 |
25 | test("returns the loading value for a loading object", () => {
26 | expect.assertions(2);
27 | const TestComponent = () => {
28 | const [data, isLoading] = useTransition({ hello: "hi" }, true);
29 | expect(isLoading).toEqual(true);
30 | expect(data).toEqual({ hello: "hi" });
31 | return null;
32 | };
33 | state = render();
34 | });
35 |
36 | test("returns the loading value for a loading object", () => {
37 | expect.assertions(2);
38 | const TestComponent = () => {
39 | const [data, isLoading] = useTransition({ hello: "hi" }, true);
40 | expect(isLoading).toEqual(true);
41 | expect(data).toEqual({ hello: "hi" });
42 | return null;
43 | };
44 | state = render();
45 | });
46 |
47 | test("returns the correct value after loading has finished", () => {
48 | expect.assertions(4);
49 | let renderCount = 0;
50 | const TestComponent = (state: {
51 | isLoading: boolean;
52 | data: string | null;
53 | }) => {
54 | renderCount = renderCount + 1;
55 | const [data, isLoading] = useTransition(state.data, state.isLoading);
56 | if (renderCount === 1) {
57 | expect(isLoading).toEqual(true);
58 | expect(data).toEqual(null);
59 | }
60 | if (renderCount === 2) {
61 | expect(isLoading).toEqual(false);
62 | expect(data).toEqual("foo");
63 | }
64 | return null;
65 | };
66 | state = render();
67 | state.rerender();
68 | });
69 |
70 | test("updates isLoading to 'true' after threshold is reached after entering loading state", () => {
71 | jest.useFakeTimers();
72 |
73 | let renderCount = 0;
74 | const TestComponent = (state: {
75 | isLoading: boolean;
76 | data: string | null;
77 | }) => {
78 | renderCount = renderCount + 1;
79 | const [data, isLoading] = useTransition(state.data, state.isLoading);
80 | if (renderCount === 1) {
81 | expect(isLoading).toEqual(false);
82 | expect(data).toEqual("foo");
83 | }
84 | if (renderCount === 2) {
85 | expect(isLoading).toEqual(false);
86 | expect(data).toEqual("foo");
87 | }
88 | if (renderCount === 3) {
89 | expect(isLoading).toEqual(true);
90 | expect(data).toEqual("foo");
91 | }
92 | return null;
93 | };
94 | state = render();
95 | state.rerender();
96 | jest.runAllTimers();
97 | });
98 |
99 | test("updates isLoading to 'false' and data to the latest value after leaving the loading state.", async () => {
100 | expect.assertions(8);
101 | jest.useFakeTimers();
102 |
103 | let onThirdRender: () => void;
104 | const didRenderForThirdTime = new Promise(
105 | (res) => (onThirdRender = res)
106 | );
107 |
108 | let renderCount = 0;
109 | const TestComponent = (state: {
110 | isLoading: boolean;
111 | data: string | null;
112 | }) => {
113 | renderCount = renderCount + 1;
114 | const [data, isLoading] = useTransition(state.data, state.isLoading);
115 | if (renderCount === 1) {
116 | expect(isLoading).toEqual(false);
117 | expect(data).toEqual("foo");
118 | }
119 | if (renderCount === 2) {
120 | expect(isLoading).toEqual(false);
121 | expect(data).toEqual("foo");
122 | }
123 | if (renderCount === 3) {
124 | expect(isLoading).toEqual(true);
125 | expect(data).toEqual("foo");
126 | onThirdRender();
127 | }
128 | if (renderCount === 4) {
129 | expect(isLoading).toEqual(false);
130 | expect(data).toEqual("hi");
131 | }
132 | return null;
133 | };
134 | state = render();
135 | state.rerender();
136 | jest.runAllTimers();
137 | // we wait until the third render occurred for our next update to ensure the component is consistently rendered with every value.
138 | await didRenderForThirdTime;
139 | state.rerender();
140 | });
141 |
--------------------------------------------------------------------------------
/src/createUseTransition.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | /**
4 | * Create a useTransition hook for a specific react reconciler.
5 | */
6 | export function createUseTransition(
7 | /**
8 | * The unstable_BatchedUpdates function for a specific react-reconciler.
9 | */
10 | batchedUpdates = (run: () => void) => run(),
11 | /**
12 | * The used setTimeout function for scheduling the timeout after which a loading indicator should be shown.
13 | * Passing this argument might be handy for testing.
14 | */
15 | _setTimeout = setTimeout
16 | ) {
17 | /**
18 | * Returns a transition state that holds the old value until the loading of the new value has finished.
19 | */
20 | return function useTransition(
21 | /**
22 | * The latest data that got loaded.
23 | */
24 | data: TType,
25 | /**
26 | * Whether a new value is currently being loaded.
27 | */
28 | isLoading: boolean,
29 | /**
30 | * Threshold after which showing a loading indicator might be useful.
31 | */
32 | shouldShowLoadingIndicatorThreshold = 300
33 | ): [TType, boolean] {
34 | const [, triggerStateUpdate] = React.useState(1);
35 | const ref = React.useRef({
36 | data,
37 | previousIsLoading: isLoading,
38 | shouldShowLoadingIndicator: isLoading,
39 | });
40 |
41 | React.useEffect(() => {
42 | if (!isLoading) {
43 | ref.current.data = data;
44 | ref.current.shouldShowLoadingIndicator = false;
45 | }
46 |
47 | let timeout: NodeJS.Timeout | null = null;
48 | if (ref.current.previousIsLoading !== isLoading && isLoading) {
49 | timeout = _setTimeout(() => {
50 | batchedUpdates(() => {
51 | ref.current.shouldShowLoadingIndicator = true;
52 | triggerStateUpdate((i) => i + 1);
53 | });
54 | }, shouldShowLoadingIndicatorThreshold);
55 | }
56 |
57 | ref.current.previousIsLoading = isLoading;
58 |
59 | return () => {
60 | if (timeout) {
61 | clearTimeout(timeout);
62 | }
63 | };
64 | }, [isLoading, triggerStateUpdate, shouldShowLoadingIndicatorThreshold]);
65 |
66 | if (!isLoading) {
67 | return [data, isLoading];
68 | }
69 |
70 | return [ref.current.data, ref.current.shouldShowLoadingIndicator];
71 | };
72 | }
73 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | export * from "./createUseTransition";
2 |
--------------------------------------------------------------------------------
/tsconfig.build-cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "outDir": "dist/cjs",
5 | "declaration": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "declaration": true,
6 | "noEmit": false
7 | },
8 | "exclude": ["src/**/*.spec.tsx"]
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "jsx": "preserve",
5 | "noEmit": true
6 | },
7 | "include": ["src"]
8 | }
9 |
--------------------------------------------------------------------------------