Skip to content

Commit 1582b72

Browse files
committed
feat: create useBetterEffect hook
0 parents  commit 1582b72

10 files changed

+3511
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
coverage

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# useBetterEffect
2+
3+
A wrapper around `React.useEffect` but with improved API
4+
5+
## Installation
6+
7+
With Yarn:
8+
9+
```bash
10+
yarn add use-better-effect
11+
```
12+
13+
With npm:
14+
15+
```bash
16+
npm install --save use-better-effect
17+
```
18+
19+
## Background
20+
21+
`useEffect` is a power tool but the API has some gotchas. See the following examples
22+
23+
```ts
24+
// not passing dependencies mean someFn runs on ever render
25+
useEffect(() => someFn());
26+
27+
// passing [] as dependencies mean someFn only run on mount
28+
useEffect(() => someFn(), []);
29+
30+
// passing [a, b] as dependencies mean someFn rerun when a or b changes
31+
useEffect(() => someFn(), [a, b]);
32+
33+
// the returned function of the passed in callback function is called on mount.
34+
useEffect(() => {
35+
someFn();
36+
return () => anotherFn();
37+
}, [a, b]);
38+
```
39+
40+
These implicit behaviors are hard to understand by just looking at the code.
41+
42+
## Improved API
43+
44+
`useBetterEffect` uses typescript function overloading to improve the developer experience when using the effect for different conditions. There are 3 supports run conditions:
45+
46+
- ON_MOUNT
47+
- EVERY_RENDER
48+
- DEPENDENCIES_CHANGED
49+
50+
For `ON_MOUNT` and `EVERY_RENDER`, passing in dependencies will result in a type error and `useBetterEffect` auto computes the dependency argument as `[]` and `undefined` respectively.
51+
52+
The cleanup function is passing as an explict optional arugment.
53+
54+
## Examples
55+
56+
```ts
57+
useBetterEffect({
58+
callbackFn: () => console.log("yay better"),
59+
cleanupFn: () => console.log("so fresh and so clean"),
60+
runCondition: "ON_MOUNT",
61+
});
62+
63+
useBetterEffect({
64+
callbackFn: () => console.log("yay better"),
65+
cleanupFn: () => console.log("so fresh and so clean"),
66+
runCondition: "EVERY_RENDER",
67+
});
68+
69+
useBetterEffect({
70+
callbackFn: () => console.log("yay better"),
71+
cleanupFn: () => console.log("so fresh and so clean"),
72+
runCondition: "DEPENDENCIES_CHANGED",
73+
dependencies: [count],
74+
});
75+
```

jest.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* For a detailed explanation regarding each configuration property and type check, visit:
3+
* https://jestjs.io/docs/configuration
4+
*/
5+
6+
export default {
7+
preset: "ts-jest",
8+
collectCoverage: true,
9+
collectCoverageFrom: ["src/**/*.ts"],
10+
coverageDirectory: "coverage",
11+
testEnvironment: "jsdom",
12+
};

package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "use-better-effect",
3+
"version": "0.0.1",
4+
"main": "dist/index.js",
5+
"module": "dist/index.js",
6+
"license": "MIT",
7+
"devDependencies": {
8+
"@testing-library/react": "^13.4.0",
9+
"@types/jest": "^29.2.4",
10+
"@types/node": "^18.11.14",
11+
"@types/react": "^18.0.26",
12+
"@types/react-dom": "^18.0.9",
13+
"jest": "^29.3.1",
14+
"jest-environment-jsdom": "^29.3.1",
15+
"react": "^18.2.0",
16+
"react-dom": "^18.2.0",
17+
"ts-jest": "^29.0.3",
18+
"ts-node": "^10.9.1",
19+
"typescript": "^4.9.4"
20+
},
21+
"peerDependencies": {
22+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
23+
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
24+
},
25+
"scripts": {
26+
"test": "jest",
27+
"build": "tsc"
28+
}
29+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useBetterEffect } from "./use-better-effect";
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useState } from "react";
2+
import { act, render } from "@testing-library/react";
3+
import { useBetterEffect } from "./use-better-effect";
4+
5+
describe("useBetterEffect", () => {
6+
beforeEach(() => {
7+
jest.clearAllMocks();
8+
});
9+
10+
describe("runCondition: ON_MOUNT", () => {
11+
const TestComponent = () => {
12+
const [count, setCount] = useState(0);
13+
useBetterEffect({
14+
callbackFn: () => {
15+
setCount(count + 1);
16+
},
17+
runCondition: "ON_MOUNT",
18+
});
19+
return <div>{count}</div>;
20+
};
21+
22+
it("should update count to 1 on mount", () => {
23+
const wrapper = render(<TestComponent />);
24+
expect(wrapper.queryByText("1")).toBeTruthy();
25+
});
26+
27+
it("should not update count on rerender", () => {
28+
const wrapper = render(<TestComponent />);
29+
expect(wrapper.queryByText("1")).toBeTruthy();
30+
wrapper.rerender(<TestComponent />);
31+
expect(wrapper.queryByText("1")).toBeTruthy();
32+
wrapper.rerender(<TestComponent />);
33+
expect(wrapper.queryByText("1")).toBeTruthy();
34+
});
35+
});
36+
37+
describe("runCondition: EVERY_RENDER", () => {
38+
const someFn = jest.fn();
39+
const TestComponent = () => {
40+
useBetterEffect({
41+
callbackFn: () => {
42+
someFn();
43+
},
44+
runCondition: "EVERY_RENDER",
45+
});
46+
return <div>EVERY_RENDER</div>;
47+
};
48+
49+
it("should call someFn on mount", () => {
50+
render(<TestComponent />);
51+
expect(someFn).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("should call someFn on rerender", () => {
55+
const wrapper = render(<TestComponent />);
56+
expect(someFn).toHaveBeenCalledTimes(1);
57+
wrapper.rerender(<TestComponent />);
58+
expect(someFn).toHaveBeenCalledTimes(2);
59+
wrapper.rerender(<TestComponent />);
60+
expect(someFn).toHaveBeenCalledTimes(3);
61+
});
62+
});
63+
64+
describe("runCondition: DEPENDENCIES_CHANGED", () => {
65+
const someFn = jest.fn();
66+
const TestComponent = () => {
67+
const [count, setCount] = useState(0);
68+
useBetterEffect({
69+
callbackFn: someFn,
70+
runCondition: "DEPENDENCIES_CHANGED",
71+
dependencies: [count],
72+
});
73+
74+
return (
75+
<div>
76+
<div>{count}</div>
77+
<button onClick={() => setCount(count + 1)}>click</button>
78+
</div>
79+
);
80+
};
81+
82+
it("should call someFn and render initial count on mount", () => {
83+
const wrapper = render(<TestComponent />);
84+
expect(wrapper.queryByText("0")).toBeTruthy();
85+
expect(someFn).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it("should call someFn when count changes", async () => {
89+
const wrapper = render(<TestComponent />);
90+
expect(wrapper.queryByText("0")).toBeTruthy();
91+
expect(someFn).toHaveBeenCalledTimes(1);
92+
act(() => {
93+
wrapper.getByText("click").click();
94+
});
95+
expect(wrapper.queryByText("1")).toBeTruthy();
96+
expect(someFn).toHaveBeenCalledTimes(2);
97+
act(() => {
98+
wrapper.getByText("click").click();
99+
});
100+
expect(wrapper.queryByText("2")).toBeTruthy();
101+
expect(someFn).toHaveBeenCalledTimes(3);
102+
});
103+
104+
it("should not call someFn when component rerenders by deps did not change", () => {
105+
const wrapper = render(<TestComponent />);
106+
expect(wrapper.queryByText("0")).toBeTruthy();
107+
expect(someFn).toHaveBeenCalledTimes(1);
108+
wrapper.rerender(<TestComponent />);
109+
expect(wrapper.queryByText("0")).toBeTruthy();
110+
expect(someFn).toHaveBeenCalledTimes(1);
111+
});
112+
});
113+
});

src/use-better-effect.hook.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useEffect } from "react";
3+
4+
import { useBetterEffect } from "./use-better-effect";
5+
6+
describe("useBetterEffect", () => {
7+
describe('runCondition: "ON_MOUNT"', () => {
8+
it("should run callbackFn on mount", () => {
9+
const callbackFn = jest.fn();
10+
renderHook(() =>
11+
useBetterEffect({ callbackFn, runCondition: "ON_MOUNT" })
12+
);
13+
expect(callbackFn).toHaveBeenCalledTimes(1);
14+
});
15+
16+
it("should not run callbackFn on rerenders", () => {
17+
const callbackFn = jest.fn();
18+
const { rerender } = renderHook(() => {
19+
useBetterEffect({
20+
callbackFn,
21+
runCondition: "ON_MOUNT",
22+
});
23+
});
24+
expect(callbackFn).toHaveBeenCalledTimes(1);
25+
rerender();
26+
expect(callbackFn).toHaveBeenCalledTimes(1);
27+
});
28+
29+
it("should run cleanupFn on unmount", () => {
30+
const cleanupFn = jest.fn();
31+
const { unmount } = renderHook(() =>
32+
useBetterEffect({
33+
callbackFn: () => {},
34+
cleanupFn,
35+
runCondition: "ON_MOUNT",
36+
})
37+
);
38+
expect(cleanupFn).toHaveBeenCalledTimes(0);
39+
unmount();
40+
expect(cleanupFn).toHaveBeenCalledTimes(1);
41+
});
42+
});
43+
44+
describe('runCondition: "EVERY_RENDER"', () => {
45+
it("should run callbackFn on mount", () => {
46+
const callbackFn = jest.fn();
47+
renderHook(() =>
48+
useBetterEffect({ callbackFn, runCondition: "EVERY_RENDER" })
49+
);
50+
expect(callbackFn).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it("should run callbackFn on every render", () => {
54+
const callbackFn = jest.fn();
55+
const { rerender } = renderHook(() =>
56+
useBetterEffect({ callbackFn, runCondition: "EVERY_RENDER" })
57+
);
58+
expect(callbackFn).toHaveBeenCalledTimes(1);
59+
rerender();
60+
expect(callbackFn).toHaveBeenCalledTimes(2);
61+
rerender();
62+
expect(callbackFn).toHaveBeenCalledTimes(3);
63+
});
64+
65+
it("should run cleanupFn on unmount", () => {
66+
const cleanupFn = jest.fn();
67+
const { unmount } = renderHook(() =>
68+
useBetterEffect({
69+
callbackFn: () => {},
70+
cleanupFn,
71+
runCondition: "EVERY_RENDER",
72+
})
73+
);
74+
expect(cleanupFn).toHaveBeenCalledTimes(0);
75+
unmount();
76+
expect(cleanupFn).toHaveBeenCalledTimes(1);
77+
});
78+
});
79+
80+
describe('runCondition: "DEPENDENCIES_CHANGED"', () => {
81+
it("should run callbackFn on mount", () => {
82+
const callbackFn = jest.fn();
83+
renderHook(() =>
84+
useBetterEffect({
85+
callbackFn,
86+
runCondition: "DEPENDENCIES_CHANGED",
87+
dependencies: [1, 2, 3],
88+
})
89+
);
90+
expect(callbackFn).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it.only("should run callbackFn on dependencies changed", () => {
94+
const callbackFn = jest.fn();
95+
const { rerender } = renderHook(
96+
({ dependencies }) => useEffect(callbackFn, dependencies),
97+
{ initialProps: { dependencies: [1, 2, 3] } }
98+
);
99+
expect(callbackFn).toHaveBeenCalledTimes(1);
100+
rerender({ dependencies: [1, 2, 3, 4] });
101+
expect(callbackFn).toHaveBeenCalledTimes(2);
102+
});
103+
104+
it("should not run cleanupFn on dependencies changed", () => {
105+
const cleanupFn = jest.fn();
106+
const { rerender } = renderHook(
107+
({ dependencies }) =>
108+
useBetterEffect({
109+
callbackFn: () => {},
110+
cleanupFn,
111+
runCondition: "DEPENDENCIES_CHANGED",
112+
dependencies,
113+
}),
114+
{ initialProps: { dependencies: [1, 2, 3] } }
115+
);
116+
expect(cleanupFn).toHaveBeenCalledTimes(0);
117+
rerender({ dependencies: [1, 2, 3, 4] });
118+
expect(cleanupFn).toHaveBeenCalledTimes(0);
119+
});
120+
121+
it("should run cleanupFn on unmount", () => {
122+
const cleanupFn = jest.fn();
123+
const { unmount } = renderHook(() =>
124+
useBetterEffect({
125+
callbackFn: () => {},
126+
cleanupFn,
127+
runCondition: "DEPENDENCIES_CHANGED",
128+
dependencies: [1, 2, 3],
129+
})
130+
);
131+
expect(cleanupFn).toHaveBeenCalledTimes(0);
132+
unmount();
133+
expect(cleanupFn).toHaveBeenCalledTimes(1);
134+
});
135+
});
136+
});

0 commit comments

Comments
 (0)