;
33 | }
34 |
35 | const {rerender} = render();
36 | screen.getByText('2');
37 |
38 | rerender();
39 | screen.getByText('2');
40 | });
41 |
--------------------------------------------------------------------------------
/packages/use-last/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.build.json",
3 | "include": ["src", "test", "types", "../../types"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/use-optional-state/.eslintrc.js:
--------------------------------------------------------------------------------
1 | require('eslint-config-molindo/setupPlugins');
2 |
3 | module.exports = {
4 | extends: ['molindo/typescript', 'molindo/react'],
5 | env: {
6 | node: true
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/packages/use-optional-state/README.md:
--------------------------------------------------------------------------------
1 | # use-optional-state
2 |
3 | [](https://npm.im/use-optional-state)
4 |
5 | A React hook to enable a component state to either be controlled or uncontrolled.
6 |
7 | ## The problem
8 |
9 | [Controlled components](https://reactjs.org/docs/forms.html#controlled-components) are a concept mostly known from form elements. They allow the owner to specify exactly what a component should render and to execute custom logic when the component calls a change handler.
10 |
11 | In contrast to this, there are [uncontrolled components](https://reactjs.org/docs/uncontrolled-components.html) which handle the state internally.
12 |
13 | The tradeoff comes down to controlled components being more flexible in their usage but uncontrolled components being easier to use if the owner is not concerned with the state. Sometimes it's desireable for an owner at least configure an initial value and to potentially [reset the child state later with a `key`](https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key).
14 |
15 | When implementing a component, it's sometimes hard to choose one or the other since there are valid use cases for both approaches.
16 |
17 | ## This solution
18 |
19 | This hook helps you to support both patterns in your components, increasing flexibility while also ensuring ease of use.
20 |
21 | Since the solution can be applied on a per-prop basis, you can also enable this behaviour for multiple props that are orthogonal (e.g. a `` component).
22 |
23 | ## Example
24 |
25 | **Implementation:**
26 |
27 | ```jsx
28 | import useOptionalState from 'use-optional-state';
29 |
30 | function Expander({
31 | expanded: controlledExpanded,
32 | initialExpanded = false,
33 | onChange
34 | }) {
35 | const [expanded, setExpanded] = useOptionalState({
36 | controlledValue: controlledExpanded,
37 | initialValue: initialExpanded,
38 | onChange
39 | });
40 |
41 | function onToggle() {
42 | setExpanded(!expanded);
43 | }
44 |
45 | return (
46 | <>
47 |
50 | {expanded &&
{children}
}
51 | >
52 | );
53 | }
54 | ```
55 |
56 | **Usage:**
57 |
58 | ```jsx
59 | // Controlled
60 |
61 |
62 | // Uncontrolled using the default value for the `initialExpanded` prop
63 |
64 |
65 | // Uncontrolled, but with a change handler if the owner wants to be notified
66 |
67 | ```
68 |
--------------------------------------------------------------------------------
/packages/use-optional-state/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-optional-state",
3 | "version": "2.2.0",
4 | "license": "MIT",
5 | "description": "A React hook to implement components that support both controlled and uncontrolled props.",
6 | "author": "Jan Amann ",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-optional-state"
10 | },
11 | "scripts": {
12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean",
13 | "build": "tsdx build --tsconfig tsconfig.json",
14 | "test": "tsdx test",
15 | "lint": "eslint src test && tsc --noEmit",
16 | "prepublish": "npm run build"
17 | },
18 | "main": "dist/index.js",
19 | "module": "dist/use-optional-state.esm.js",
20 | "typings": "dist/index.d.ts",
21 | "files": [
22 | "README.md",
23 | "dist"
24 | ],
25 | "peerDependencies": {
26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
28 | },
29 | "dependencies": {
30 | "tslib": "^2.0.0",
31 | "use-constant": "^2.0.0"
32 | },
33 | "publishConfig": {
34 | "access": "public"
35 | },
36 | "devDependencies": {
37 | "@testing-library/dom": "^10.0.0",
38 | "@testing-library/react": "^16.1.0",
39 | "@types/react": "^19.0.0",
40 | "@types/react-dom": "^19.0.0",
41 | "eslint": "8.13.0",
42 | "eslint-config-molindo": "^6.0.0",
43 | "react": "^19.0.0",
44 | "react-dom": "^19.0.0",
45 | "tsdx": "^0.14.1",
46 | "typescript": "^4.8.2"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/use-optional-state/src/index.tsx:
--------------------------------------------------------------------------------
1 | import {useState, useCallback} from 'react';
2 | import useConstant from 'use-constant';
3 |
4 | // Controlled
5 | export default function useOptionalState(opts: {
6 | controlledValue: Value;
7 | initialValue?: Value | undefined;
8 | onChange?(value: Value): void;
9 | }): [Value, (value: Value) => void];
10 |
11 | // Uncontrolled with initial value
12 | export default function useOptionalState(opts: {
13 | controlledValue?: Value | undefined;
14 | initialValue: Value;
15 | onChange?(value: Value): void;
16 | }): [Value | undefined, (value: Value) => void];
17 |
18 | // Uncontrolled without initial value
19 | export default function useOptionalState(opts: {
20 | controlledValue?: Value | undefined;
21 | initialValue?: Value;
22 | onChange?(value: Value): void;
23 | }): [Value | undefined, (value: Value) => void];
24 |
25 | /**
26 | * Enables a component state to be either controlled or uncontrolled.
27 | */
28 | export default function useOptionalState({
29 | controlledValue,
30 | initialValue,
31 | onChange
32 | }: {
33 | controlledValue?: Value | undefined;
34 | initialValue?: Value | undefined;
35 | onChange?(value: Value): void;
36 | }) {
37 | const isControlled = controlledValue !== undefined;
38 | const initialIsControlled = useConstant(() => isControlled);
39 | const [stateValue, setStateValue] = useState(initialValue);
40 |
41 | if (__DEV__) {
42 | if (initialIsControlled && !isControlled) {
43 | throw new Error(
44 | 'Can not change from controlled to uncontrolled mode. If `undefined` needs to be used for controlled values, please use `null` instead.'
45 | );
46 | }
47 |
48 | if (!initialIsControlled && isControlled) {
49 | throw new Error(
50 | 'Can not change from uncontrolled to controlled mode. Please supply an initial value other than `undefined` to make the state controlled over its lifetime. If `undefined` needs to be used for controlled values, please use `null` instead.'
51 | );
52 | }
53 | }
54 |
55 | const value = isControlled ? controlledValue : stateValue;
56 |
57 | const onValueChange = useCallback(
58 | (nextValue: Value) => {
59 | if (!isControlled) setStateValue(nextValue);
60 | if (onChange) onChange(nextValue);
61 | },
62 | [isControlled, onChange]
63 | );
64 |
65 | return [value, onValueChange];
66 | }
67 |
--------------------------------------------------------------------------------
/packages/use-optional-state/test/index.test.tsx:
--------------------------------------------------------------------------------
1 | import {fireEvent, render, screen} from '@testing-library/react';
2 | import * as React from 'react';
3 | import useOptionalState from '../src';
4 |
5 | (global as any).__DEV__ = true;
6 |
7 | type Props = {
8 | expanded?: boolean;
9 | initialExpanded?: boolean;
10 | onChange?(expanded: boolean): void;
11 | };
12 |
13 | function Expander({
14 | expanded: controlledExpanded,
15 | initialExpanded,
16 | onChange
17 | }: Props) {
18 | const [expanded, setExpanded] = useOptionalState({
19 | controlledValue: controlledExpanded,
20 | initialValue: initialExpanded,
21 | onChange
22 | });
23 |
24 | function onToggle() {
25 | setExpanded(!expanded);
26 | }
27 |
28 | return (
29 | <>
30 |
33 | {expanded &&
Children
}
34 | >
35 | );
36 | }
37 |
38 | it('supports a controlled mode', () => {
39 | const onChange = jest.fn();
40 |
41 | const {rerender} = render();
42 | screen.getByText('Children');
43 | fireEvent.click(screen.getByText('Toggle'));
44 | expect(onChange).toHaveBeenLastCalledWith(false);
45 |
46 | rerender();
47 | expect(screen.queryByText('Children')).toBe(null);
48 | fireEvent.click(screen.getByText('Toggle'));
49 | expect(onChange).toHaveBeenLastCalledWith(true);
50 |
51 | rerender();
52 | screen.getByText('Children');
53 | });
54 |
55 | it('supports an uncontrolled mode', () => {
56 | const onChange = jest.fn();
57 |
58 | render();
59 | screen.getByText('Children');
60 | fireEvent.click(screen.getByText('Toggle'));
61 | expect(onChange).toHaveBeenLastCalledWith(false);
62 |
63 | expect(screen.queryByText('Children')).toBe(null);
64 | fireEvent.click(screen.getByText('Toggle'));
65 | expect(onChange).toHaveBeenLastCalledWith(true);
66 | screen.getByText('Children');
67 | });
68 |
69 | it('supports an uncontrolled mode with no initial value', () => {
70 | const onChange = jest.fn();
71 |
72 | render();
73 | expect(screen.queryByText('Children')).toBe(null);
74 | fireEvent.click(screen.getByText('Toggle'));
75 | expect(onChange).toHaveBeenLastCalledWith(true);
76 |
77 | screen.getByText('Children');
78 | fireEvent.click(screen.getByText('Toggle'));
79 | expect(onChange).toHaveBeenLastCalledWith(false);
80 | expect(screen.queryByText('Children')).toBe(null);
81 | });
82 |
83 | it('allows to use an initial value without a change handler', () => {
84 | // Maybe the value is read from the DOM directly
85 | render();
86 | });
87 |
88 | it('allows using a controlled value without a change handler', () => {
89 | // Forced static value
90 | render();
91 | });
92 |
93 | it('uses the controlled value when both a controlled as well as an initial value is provided', () => {
94 | render();
95 | screen.getByText('Children');
96 | });
97 |
98 | it('throws when switching from uncontrolled to controlled mode', () => {
99 | const {rerender} = render();
100 |
101 | expect(() => rerender()).toThrow(
102 | /Can not change from uncontrolled to controlled mode./
103 | );
104 | });
105 |
106 | it('throws when switching from controlled to uncontrolled mode', () => {
107 | const {rerender} = render();
108 |
109 | expect(() => rerender()).toThrow(
110 | /Can not change from controlled to uncontrolled mode./
111 | );
112 | });
113 |
114 | /**
115 | * Type signature tests
116 | */
117 |
118 | function TestTypes() {
119 | const controlled = useOptionalState({
120 | controlledValue: true
121 | });
122 | controlled[0].valueOf();
123 |
124 | const uncontrolledWithInitialValue = useOptionalState({
125 | initialValue: true
126 | });
127 | // @ts-expect-error Null-check would be necessary
128 | uncontrolledWithInitialValue[0].valueOf();
129 |
130 | const uncontrolledWithoutInitialValue = useOptionalState({});
131 | // @ts-expect-error Null-check would be necessary
132 | uncontrolledWithoutInitialValue[0].valueOf();
133 |
134 | // Only used for type tests; mark the variables as used
135 | // eslint-disable-next-line no-unused-expressions
136 | [controlled, uncontrolledWithInitialValue, uncontrolledWithoutInitialValue];
137 | }
138 |
139 | // Expected return type: `[boolean, (value: boolean) => void]`
140 | function Controlled(opts: {controlledValue: boolean; initialValue?: boolean}) {
141 | const [value, setValue] = useOptionalState(opts);
142 |
143 | setValue(true);
144 | return value.valueOf();
145 | }
146 |
147 | // Expected return type: `[boolean | undefined, (value: boolean) => void]`
148 | // Note that theoretically `undefined` shouldn't be possible here,
149 | // but the types seem to be quite hard to get right.
150 | function UncontrolledWithInitialValue(opts: {
151 | controlledValue?: boolean;
152 | initialValue: boolean;
153 | }) {
154 | const [value, setValue] = useOptionalState(opts);
155 |
156 | setValue(true);
157 |
158 | // @ts-expect-error Null-check would be necessary
159 | return value.valueOf();
160 | }
161 |
162 | // Expected return type: `[boolean | undefined, (value: boolean) => void]`
163 | function UncontrolledWithoutInitialValue(opts: {
164 | controlledValue?: boolean;
165 | initialValue?: boolean;
166 | }) {
167 | const [value, setValue] = useOptionalState(opts);
168 |
169 | setValue(true);
170 |
171 | // @ts-expect-error Null-check would be necessary
172 | return value.valueOf();
173 | }
174 |
175 | // Only used for type tests; mark the functions as used
176 | // eslint-disable-next-line no-unused-expressions
177 | [
178 | TestTypes,
179 | Controlled,
180 | UncontrolledWithInitialValue,
181 | UncontrolledWithoutInitialValue
182 | ];
183 |
--------------------------------------------------------------------------------
/packages/use-optional-state/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.build.json",
3 | "include": ["src", "test", "types", "../../types"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/use-presence/.eslintrc.js:
--------------------------------------------------------------------------------
1 | require('eslint-config-molindo/setupPlugins');
2 |
3 | module.exports = {
4 | extends: ['molindo/typescript', 'molindo/react'],
5 | env: {
6 | node: true
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/packages/use-presence/README.md:
--------------------------------------------------------------------------------
1 | # use-presence
2 |
3 | [](https://npm.im/use-presence)
4 |
5 | A 1kb React hook to animate the presence of an element.
6 |
7 |
8 |
9 | [Demo app](https://codesandbox.io/s/usepresence-demo-1u6vq?file=/src/Expander.js)
10 |
11 | ## The problem
12 |
13 | There are two problems that you have to solve when animating the presence of an element:
14 |
15 | 1. During enter animations, you have to render an initial state where the element is hidden and only after this has flushed to the DOM, you can can animate the final state that the element should animate towards.
16 | 2. Exit animations are a bit tricky in React, since this typically means that a component unmounts. However when the component has already unmounted, you can't animate it anymore. A workaround is often to keep the element mounted, but that keeps unnecessary elements around and can hurt accessibility, as hidden interactive elements might still be focusable.
17 |
18 | ## This solution
19 |
20 | This hook provides a lightweight solution where the animating element is only mounted the minimum of time, while making sure the animation is fully visible to the user. The rendering is left to the user to support all kinds of styling solutions.
21 |
22 | ## Example
23 |
24 | ```jsx
25 | import usePresence from 'use-presence';
26 |
27 | function Expander({children, isOpen, transitionDuration = 500}) {
28 | const {isMounted, isVisible, isAnimating} = usePresence(isOpen, {transitionDuration});
29 |
30 | if (!isMounted) {
31 | return null;
32 | }
33 |
34 | return (
35 |
49 | {children}
50 |
51 | );
52 | }
53 | ```
54 |
55 | ## API
56 |
57 | ```tsx
58 | const {
59 | /** Should the component be returned from render? */
60 | isMounted,
61 | /** Should the component have its visible styles applied? */
62 | isVisible,
63 | /** Is the component either entering or exiting currently? */
64 | isAnimating,
65 | /** Is the component entering currently? */
66 | isEntering,
67 | /** Is the component exiting currently? */
68 | isExiting
69 | } = usePresence(
70 | /** Indicates whether the component that the resulting values will be used upon should be visible to the user. */
71 | isVisible: boolean,
72 | opts: {
73 | /** Duration in milliseconds used both for enter and exit transitions. */
74 | transitionDuration: number;
75 | /** Duration in milliseconds used for enter transitions (overrides `transitionDuration` if provided). */
76 | enterTransitionDuration: number;
77 | /** Duration in milliseconds used for exit transitions (overrides `transitionDuration` if provided). */
78 | exitTransitionDuration: number;
79 | /** Opt-in to animating the entering of an element if `isVisible` is `true` during the initial mount. */
80 | initialEnter?: boolean;
81 | }
82 | )
83 | ```
84 |
85 | ## `usePresenceSwitch`
86 |
87 | If you have multiple items where only one is visible at a time, you can use the supplemental `usePresenceSwitch` hook to animate the items in and out. Previous items will exit before the next item transitions in.
88 |
89 | ### API
90 |
91 | ```tsx
92 | const {
93 | /** The item that should currently be rendered. */
94 | mountedItem,
95 | /** Returns all other properties from `usePresence`. */
96 | ...rest
97 | } = usePresence(
98 | /** The current item that should be visible. If `undefined` is passed, the previous item will animate out. */
99 | item: ItemType | undefined,
100 | /** See the `opts` argument of `usePresence`. */
101 | opts: Parameters[1]
102 | )
103 | ```
104 |
105 | ### Example
106 |
107 | ```jsx
108 | const tabs = [
109 | {
110 | title: 'Tab 1',
111 | content: 'Tab 1 content'
112 | },
113 | {
114 | title: 'Tab 2',
115 | content: 'Tab 2 content'
116 | },
117 | {
118 | title: 'Tab 3',
119 | content: 'Tab 3 content'
120 | },
121 | ];
122 |
123 | function Tabs() {
124 | const [tabIndex, setTabIndex] = useState(0);
125 |
126 | return (
127 | <>
128 | {tabs.map((tab, index) => (
129 |
132 | ))}
133 |
134 | {tabs[tabIndex].content}
135 |
136 | >
137 | );
138 | }
139 |
140 | function TabContent({ children, transitionDuration = 500 }) {
141 | const {
142 | isMounted,
143 | isVisible,
144 | mountedItem,
145 | } = usePresenceSwitch(children, { transitionDuration });
146 |
147 | if (!isMounted) {
148 | return null;
149 | }
150 |
151 | return (
152 |
33 | ...
34 | >
35 | );
36 | }
37 | ```
38 |
39 | Here are some issues with this code:
40 |
41 | 1. The button can be submitted multiple times while the operation is pending. The fix is adding more state to track the loading state.
42 | 2. When the button is clicked multiple times, there's a race condition which result will be shown eventually.
43 | 3. When the component unmounts in the middle of the request, you'll see the dreaded "Can't perform a React state update on an unmounted component" warning.
44 | 4. If an error is received, it won't be removed when a new attempt is made – even if a subsequent request succeeds.
45 |
46 | The list goes on but the point is: **Handling async callbacks in React components is hard**.
47 |
48 | Maybe you've heard that you can avoid these issues by moving your code into `useEffect`, but [that hook has its own peculiarities to be aware of](https://overreacted.io/a-complete-guide-to-useeffect/).
49 |
50 | ## This solution
51 |
52 | This is a custom hook that attempts to remove all the complexity that comes with handling asynchronicity in callbacks correctly.
53 |
54 | **Features:**
55 |
56 | - Feels like synchronous programming – no `useEffect`.
57 | - Pending requests are canceled when they are interrupted by another request.
58 | - Impossible states like having a result and error simultaneously are prevented.
59 | - When you're using TypeScript you'll benefit from additional guardrails.
60 | - If your asynchronous callback reaches through multiple levels of components, you can subscribe to the promise result right on the level where you need it – no need to pass down a loading and error state. If desired, you can subscribe in multiple components at the same time.
61 |
62 | ## Example
63 |
64 | ```jsx
65 | import usePromised from 'use-promised';
66 |
67 | function FeedbackForm() {
68 | const [promise, setPromise] = usePromised();
69 |
70 | function onSubmit() {
71 | setPromise(API.submitFeedback());
72 | }
73 |
74 | return (
75 | <>
76 |
83 | {promise.fulfilled && (
84 |