Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(createGlobalState)!: Align API with React.useState #1021

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions docs/createGlobalState.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,59 @@ A React hook which creates a globally shared state.

## Usage


```ts
const useGlobalState = createGlobalState<T>(defaultValue: T)
...
const [ value, setValue ] = useGlobalState(initialValue?: T)
```

Here you can see that there is both a `defaultValue` and an `initialValue`. Together, they dictate what the starting value will be.
* `defaultValue` is the value returned on the first use of the `useGlobalState()` hook when no `initialValue` is provided, and allows you to ensure that there will always be valid value available.
* `initialValue` allows overriding the `defaultValue` on the very first rendering of the hook within the app. This also allows the hook to be a drop in replacement for `React.useState`. If no `initialValue` is provided, the `defaultValue` will be used.

### Example:

In the following example, the starting value with be `1`, because it overrides the `defaultValue`, and because that invocation is rendered before the `useGlobalValue(2)`, and `useGlobalValue(3)`, whose `initialValue` parameters are ignored because they are not the first invocations.

```tsx
const useGlobalValue = createGlobalState<number>(0);
const useMyGlobalState = createGlobalState<number>(0);

const CompA: FC = () => {
const [value, setValue] = useGlobalValue();
const SetValueDirectly: FC = () => {
const [value, setValue] = useMyGlobalState(2);

return <button onClick={() => setValue(value + 1)}>+</button>;
return (
<div>
<p>{value}</p>
<div>
<button onClick={() => setValue(value + 1)}>+</button>
<button onClick={() => setValue(value - 1)}>-</button>
</div>
</div>
);
};

const CompB: FC = () => {
const [value, setValue] = useGlobalValue();
const SetValueWithFunctionalUpdate: FC = () => {
const [value, setValue] = useMyGlobalState(1);

return <button onClick={() => setValue(value - 1)}>-</button>;
return (
<div>
<p>{value}</p>
<div>
<button onClick={() => setValue(val => val + 1)}>+</button>
<button onClick={() => setValue(val => val - 1)}>-</button>
</div>
</div>
);
};

const Demo: FC = () => {
const [value] = useGlobalValue();
const [value] = useGlobalValue(1);
return (
<div>
<p>{value}</p>
<CompA />
<CompB />
<SetValueDirectly />
<SetValueWithFunctionalUpdate />
</div>
);
};
Expand Down
47 changes: 33 additions & 14 deletions src/createGlobalState.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
/* eslint-disable */
import { useState } from 'react';
import { useState, Dispatch } from 'react';
import { resolveHookState, InitialHookState, HookState } from './util/resolveHookState'
import useEffectOnce from './useEffectOnce';
import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
import { useFirstMountState } from './useFirstMountState';

export function createGlobalState<S = any>(initialState?: S) {
const store: { state: S | undefined; setState: (state: S) => void; setters: any[] } = {
state: initialState,
setState(state: S) {
store.state = state;
export type GlobalStateHookReturn<S> = [ S, Dispatch<HookState<S>> ]
export type GlobalStateHook<S> = (initialState?: InitialHookState<S>) => GlobalStateHookReturn<S>
interface StateStore<S> {
initialized: boolean
state: S
setState: Dispatch<HookState<S>>
setters: Dispatch<HookState<S>>[]
}
export function createGlobalState<S = any>(defaultState: S): GlobalStateHook<S> {
const store: StateStore<S> = {
initialized: false,
state: defaultState,
setState(newState: HookState<S>) {
store.state = resolveHookState(newState, store.state);
store.setters.forEach(setter => setter(store.state));
},
setters: [],
};

return (): [S | undefined, (state: S) => void] => {
const [globalState, stateSetter] = useState<S | undefined>(store.state);
// unlike in useState, don't extend the type with `undefined` when the initializer
// is ommited because it may already be defined by the defaultState.
return function(initialState?: InitialHookState<S>): GlobalStateHookReturn<S> {
// Prevent clobbering defaultState or existing state if no argument was passed
if (!store.initialized && arguments.length > 0) {
// coerce to S because typescript doesn't detect that if arguments.length > 0
// is then initialState is S. Using spread parameter syntax might work here
// but it would be less readable, I think, and still require a comment explainer.
store.state = resolveHookState(initialState) as S;
}
const [globalState, stateSetter] = useState<S>(store.state);

useEffectOnce(() => () => {
store.setters = store.setters.filter(setter => setter !== stateSetter);
});

useIsomorphicLayoutEffect(() => {
if (!store.setters.includes(stateSetter)) {
store.setters.push(stateSetter);
}
});
const isFirstMount = useFirstMountState();
if (isFirstMount && !store.setters.includes(stateSetter)) {
store.setters.push(stateSetter);
}

store.initialized = true;
return [globalState, store.setState];
};
}
Expand Down
36 changes: 26 additions & 10 deletions stories/createGlobalState.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,43 @@ import React, { FC } from "react";
import { createGlobalState } from "../src";
import ShowDocs from "./util/ShowDocs";

const useGlobalValue = createGlobalState<number>(0);
const useMyGlobalState = createGlobalState<number>(0);

const CompA: FC = () => {
const [value, setValue] = useGlobalValue();
const SetValueDirectly: FC = () => {
const [value, setValue] = useMyGlobalState(2);

return <button onClick={() => setValue(value + 1)}>+</button>;
return (
<div>
<p>{value}</p>
<div>
<button onClick={() => setValue(value + 1)}>+</button>
<button onClick={() => setValue(value - 1)}>-</button>
</div>
</div>
);
};

const CompB: FC = () => {
const [value, setValue] = useGlobalValue();
const SetValueWithFunctionalUpdate: FC = () => {
const [value, setValue] = useMyGlobalState(3);

return <button onClick={() => setValue(value - 1)}>-</button>;
return (
<div>
<p>{value}</p>
<div>
<button onClick={() => setValue(val => val + 1)}>+</button>
<button onClick={() => setValue(val => val - 1)}>-</button>
</div>
</div>
);
};

const Demo: FC = () => {
const [value] = useGlobalValue();
const [value] = useMyGlobalState(1);
return (
<div>
<p>{value}</p>
<CompA />
<CompB />
<SetValueDirectly />
<SetValueWithFunctionalUpdate />
</div>
);
};
Expand Down
62 changes: 56 additions & 6 deletions tests/createGlobalState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,66 @@ describe('useGlobalState', () => {
expect(createGlobalState).toBeDefined();
});

it('both components should be updated', () => {
it('both components should be updated directly', () => {
const useGlobalValue = createGlobalState(0);
const { result: result1 } = renderHook(() => useGlobalValue());
const { result: result2 } = renderHook(() => useGlobalValue());
expect(result1.current[0] === 0);
expect(result2.current[0] === 0);
let [ state1, setState1 ] = result1.current;
let [ state2 ] = result2.current;
expect(state1).toBe(0);
expect(state2).toBe(0);
act(() => {
result1.current[1](1);
setState1(1);
});
expect(result1.current[0] === 1);
expect(result2.current[0] === 1);
[ state1, setState1 ] = result1.current;
[ state2 ] = result2.current;
expect(state1).toBe(1);
expect(state2).toBe(1);
});

it('both components should be updated via update function', () => {
const useGlobalValue = createGlobalState(0);
const { result: result1 } = renderHook(() => useGlobalValue());
const { result: result2 } = renderHook(() => useGlobalValue());
let [ state1, setState1 ] = result1.current;
let [ state2 ] = result2.current;
expect(state1).toBe(0);
expect(state2).toBe(0);
act(() => setState1(x => x + 1));
[ state1, setState1 ] = result1.current;
[ state2 ] = result2.current;
expect(state1).toBe(1);
expect(state2).toBe(1);
});

it('passing initialState value should override defaultState on first use', () => {
const useGlobalValue = createGlobalState(0);
const { result } = renderHook(() => useGlobalValue(1));
let [ state ] = result.current;
expect(state).toBe(1);
})

it('passing initialState function should override defaultState on first use', () => {
const useGlobalValue = createGlobalState(0);
const { result } = renderHook(() => useGlobalValue(() => 1));
let [ state ] = result.current;
expect(state).toBe(1);
})

it('passing initialState should be ignored after first use', () => {
const useGlobalValue = createGlobalState(1);
const { result: result1 } = renderHook(() => useGlobalValue(2));
const { result: result2 } = renderHook(() => useGlobalValue(3));
let [ state1 ] = result1.current;
let [ state2 ] = result2.current;
expect(state1).toBe(2);
expect(state2).toBe(2);
})

it('an unpassed initialState should not clobber defaultState', () => {
const useGlobalValue = createGlobalState(1);
const { result } = renderHook(() => useGlobalValue());
let [ state ] = result.current;
expect(state).toBe(1)
})
});