Skip to content

Commit

Permalink
feat: add initializeWithValue option to useCookie hook
Browse files Browse the repository at this point in the history
BREAKING CHANGE: default behaviour for browsers is to fetch cookie
value on state initialisation.

SSR remains untouched, but requires implicit setting of
`initializeWithValue` option to false, to avoid hydration mismatch.
  • Loading branch information
xobotyi committed Jun 14, 2021
1 parent 3019003 commit f94a1be
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 24 deletions.
24 changes: 20 additions & 4 deletions src/useCookie/__docs__/story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ Manages a single cookie.
- Hooks that managing same key on same page - are synchronised. This synchronisation does not
involve cross-tabs sync or triggering on changes that performed by third-party code.

> **_This hook provides stable API, meaning returned methods does not change between renders_**
> Uses `null` values as indicator of cookie absence, `undefined` value means that cookie value
> hasn't been fetched yet.
> While using SSR, to avoid hydration mismatch, consider setting `initializeWithValue` option
> to `false`, this will yield `undefined` state on first render and defer value fetch till effects
> execution stage.
#### Example

<Canvas columns={3}>
Expand All @@ -25,26 +34,33 @@ Manages a single cookie.
## Reference

```ts
export type IUseCookieOptions = Cookies.CookieAttributes & {
initializeWithValue?: boolean;
};

export type IUseCookieReturn = [
value: undefined | null | string,
set: (value: string) => void,
remove: () => void,
fetch: () => void
];

export function useCookie(key: string, options?: Cookies.CookieAttributes): IUseCookieReturn;
export function useCookie(key: string, options: IUseCookieOptions = {}): IUseCookieReturn;
```

#### Arguments

- **key** _`string`_ - Cookie name to manage.
- **options** _`Cookies.CookieAttributes`_ _(default: undefined)_ - Cookie options that will be
used during cookie set and delete.
- **options** _`IUseCookieOptions`_ _(default: {})_ - Cookie options that will be
used during cookie set and delete. Has only one extra option, that relates to the hook itself:
- **initializeWithValue** _`boolean`_ _(default: undefined)_ - Whether to initialize state with
cookie value or initialize with `undefined` state.
_Default to `false` during SSR._

#### Return

0. **state** - cookie value, `undefined` means it is not fetched yet, `null` means absence of
cookie.
1. **det** - Method to set new cookie value.
1. **set** - Method to set new cookie value.
2. **remove** - Method to remove cookie.
3. **fetch** - Method to re-fetch cookie value.
15 changes: 12 additions & 3 deletions src/useCookie/__tests__/dom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,17 @@ describe('useCookie', () => {
expect(result.error).toBeUndefined();
});

it('should return undefined on first render', () => {
it('should return cookie value on first render', () => {
Cookies.set('react-hookz', 'awesome');

const { result } = renderHook(() => useCookie('react-hookz'));
expect((result.all[0] as IUseCookieReturn)[0]).toBe('awesome');

Cookies.remove('react-hookz');
});

it('should return undefined on first render if `initializeWithValue` set to false', () => {
const { result } = renderHook(() => useCookie('react-hookz', { initializeWithValue: false }));
expect((result.all[0] as IUseCookieReturn)[0]).toBeUndefined();
});

Expand All @@ -62,7 +71,7 @@ describe('useCookie', () => {
result.current[1]('awesome');
});
expect(result.current[0]).toBe('awesome');
expect(setSpy).toBeCalledWith('react-hookz', 'awesome', undefined);
expect(setSpy).toBeCalledWith('react-hookz', 'awesome', {});
Cookies.remove('react-hookz');
});

Expand All @@ -79,7 +88,7 @@ describe('useCookie', () => {
result.current[2]();
});
expect(result.current[0]).toBe(null);
expect(removeSpy).toBeCalledWith('react-hookz', undefined);
expect(removeSpy).toBeCalledWith('react-hookz', {});
Cookies.remove('react-hookz');
});

Expand Down
70 changes: 56 additions & 14 deletions src/useCookie/useCookie.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { Dispatch, useCallback, useEffect } from 'react';
import * as Cookies from 'js-cookie';
import { useSafeState } from '../useSafeState/useSafeState';
import { useSyncedRef } from '../useSyncedRef/useSyncedRef';

export type IUseCookieReturn = [
value: undefined | null | string,
set: (value: string) => void,
remove: () => void,
fetch: () => void
];
import { isBrowser } from '../util/const';
import { useFirstMountState } from '../useFirstMountState/useFirstMountState';
import { useMountEffect } from '../useMountEffect/useMountEffect';

const cookiesSetters = new Map<string, Set<Dispatch<string | null>>>();

Expand Down Expand Up @@ -53,13 +50,43 @@ const invokeRegisteredSetters = (
});
};

export type IUseCookieOptions<
InitializeWithValue extends boolean | undefined = boolean | undefined
> = Cookies.CookieAttributes &
(InitializeWithValue extends undefined
? {
/**
* Whether to initialize state with cookie value or initialize with `undefined` state.
*
* Default to false during SSR
*
* @default true
*/
initializeWithValue?: InitializeWithValue;
}
: {
initializeWithValue: InitializeWithValue;
});

export type IUseCookieReturn<V extends undefined | null | string = undefined | null | string> = [
value: V,
set: (value: string) => void,
remove: () => void,
fetch: () => void
];

export function useCookie(key: string, options: IUseCookieOptions<false>): IUseCookieReturn;
export function useCookie(
key: string,
options?: IUseCookieOptions
): IUseCookieReturn<null | string>;
/**
* Manages a single cookie.
*
* @param key Cookie name to manage.
* @param options Cookie options that will be used during cookie set and delete.
*/
export function useCookie(key: string, options?: Cookies.CookieAttributes): IUseCookieReturn {
export function useCookie(key: string, options: IUseCookieOptions = {}): IUseCookieReturn {
// no need to test it, dev-only notification about 3rd party library requirement
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development' && typeof Cookies === 'undefined') {
Expand All @@ -68,32 +95,47 @@ export function useCookie(key: string, options?: Cookies.CookieAttributes): IUse
);
}

const [state, setState] = useSafeState<string | null>();
// eslint-disable-next-line prefer-const
let { initializeWithValue = true, ...cookiesOptions } = options;

if (!isBrowser) {
initializeWithValue = false;
}

const methods = useSyncedRef({
set: (value: string) => {
setState(value);
Cookies.set(key, value, options);
Cookies.set(key, value, cookiesOptions);
// update all other hooks managing the same key
invokeRegisteredSetters(key, value, setState);
},
remove: () => {
setState(null);
Cookies.remove(key, options);
Cookies.remove(key, cookiesOptions);
invokeRegisteredSetters(key, null, setState);
},
fetchVal: () => Cookies.get(key) ?? null,
fetch: () => {
const val = Cookies.get(key) ?? null;
const val = methods.current.fetchVal();
setState(val);
invokeRegisteredSetters(key, val, setState);
},
});

const isFirstMount = useFirstMountState();
const [state, setState] = useSafeState<string | null | undefined>(
isFirstMount && initializeWithValue ? methods.current.fetchVal() : undefined
);

useMountEffect(() => {
if (!initializeWithValue) {
methods.current.fetch();
}
});

useEffect(() => {
registerSetter(key, setState);

methods.current.fetch();

return () => {
unregisterSetter(key, setState);
};
Expand Down
2 changes: 1 addition & 1 deletion src/useLocalStorageValue/__docs__/story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Manages a single LocalStorage key.
> impossible to separate null value fom 'no such value' API result which is also `null`.
> While using SSR, to avoid hydration mismatch, consider setting `initializeWithStorageValue` option to
> `false`, this will yield `undefined` state on first render and defer value fetch to effects
> `false`, this will yield `undefined` state on first render and defer value fetch till effects
> execution stage.
#### Example
Expand Down
2 changes: 1 addition & 1 deletion src/useSessionStorageValue/__docs__/story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Manages a single SessionStorage key.
> impossible to separate null value fom 'no such value' API result which is also `null`.
> While using SSR, to avoid hydration mismatch, consider setting `initializeWithStorageValue` option to
> `false`, this will yield `undefined` state on first render and defer value fetch to effects
> `false`, this will yield `undefined` state on first render and defer value fetch till effects
> execution stage.
#### Example
Expand Down
2 changes: 1 addition & 1 deletion src/useStorageValue/useStorageValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type IUseStorageValueOptions<
} & (InitializeWithValue extends undefined
? {
/**
* Whether to perform value fetch from storage or initialize with `undefined` state.
* Whether to initialize state with storage value or initialize with `undefined` state.
*
* Default to false during SSR
*
Expand Down

0 comments on commit f94a1be

Please sign in to comment.