Skip to content

Commit

Permalink
feat(createGlobalState)!: Align API with React.useState
Browse files Browse the repository at this point in the history
This commit adds support for functional updates to align with the React.useState
API. Type definitions are updated to match useState.

This might break existing code that expects functions to be held in the global
state, rather than applied as an update.

This change may also cause new type errors.
  • Loading branch information
Nathan Spaeth committed Mar 5, 2020
1 parent 216c528 commit 31a74f0
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 34 deletions.
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
36 changes: 28 additions & 8 deletions src/createGlobalState.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
/* eslint-disable */
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useState, Dispatch } from 'react';
import { resolveHookState, InitialHookState, HookState } from './util/resolveHookState'
import useEffectOnce from './useEffectOnce';

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.setters.forEach(setter => setter(store.state));
},
setters: [],
};

return (): [S | undefined, (state: S) => void] => {
const [globalState, stateSetter] = useState<S | undefined>(store.state);
// unlike in useState, here initialState is optional because there will always
// be a valid state provided by 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);
Expand All @@ -25,6 +44,7 @@ export function createGlobalState<S = any>(initialState?: S) {
}
});

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
64 changes: 58 additions & 6 deletions tests/createGlobalState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,68 @@ 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 === 0);
expect(state2 === 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 === 1);
expect(state2 === 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 === 0);
expect(state2 === 0);
act(() => {
setState1(x => x + 1);
});
[ state1, setState1 ] = result1.current;
[ state2 ] = result2.current;
expect(state1 === 1);
expect(state2 === 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 === 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 === 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 === 2);
expect(state2 === 2);
})

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

0 comments on commit 31a74f0

Please sign in to comment.