Repository: streamich/react-use
Pull request #1214 (2020-05-16 13:05:40)

v15.0
# Description

v15 release with a number of breaking changes.



Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 0b76ccb7..dbfac272 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,3 +1,100 @@
 # These are supported funding model platforms
 
-github: streamich
+github: [
+  "streamich",
+  "wardoost",
+  "xobotyi",
+  "Belco90",
+  "ankithkonda",
+  "ayush987goyal",
+  "NullVoxPopuli",
+  "lintuming",
+  "Granipouss",
+  "ythecombinator",
+  "james2406",
+  "jakapatb",
+  "MrHuangJser",
+  "zaguiini",
+  "ppeeou",
+  "liuyuchenzh",
+  "brickspert",
+  "artywhite",
+  "PetterIve",
+  "realdennis",
+  "lvl99",
+  "gelove",
+  "KusStar",
+  "xiaoxiangmoe",
+  "nmccready",
+  "mattleonowicz",
+  "kevinnorris",
+  "dubzzz",
+  "dependabot[bot]",
+  "ShizukuIchi",
+  "ManojBahuguna",
+  "Jivings",
+  "Dosant",
+  "zsh2401",
+  "xiaoboost",
+  "revskill10",
+  "mtinner",
+  "monkeywithacupcake",
+  "mitchheddles",
+  "maxzitron",
+  "macinjoke",
+  "jeetiss",
+  "ilyalesik",
+  "hijiangtao",
+  "f",
+  "elliottsj",
+  "droganov",
+  "denysdovhan",
+  "dabuside",
+  "benneq",
+  "azukaar",
+  "ariesjia",
+  "andrico1234",
+  "adesurirey",
+  "OBe95",
+  "FredyC",
+  "Cretezy",
+  "zyy7259",
+  "zslabs",
+  "vinitsood",
+  "uxitten",
+  "thevtm",
+  "tanem",
+  "suyingtao",
+  "srph",
+  "rkostrzewski",
+  "qianL93",
+  "o-alexandrov",
+  "nucleartux",
+  "natew",
+  "maxmalov",
+  "liaoyinglong",
+  "koenvanzuijlen",
+  "josmardias",
+  "jeemyeong",
+  "jazzqi",
+  "jakyle",
+  "jakeboone02",
+  "inker",
+  "glarivie",
+  "garrettmaring",
+  "dovidweisz",
+  "daniel-hauser",
+  "d-asensio",
+  "charlax",
+  "TylerR909",
+  "Rogdham",
+  "OctoD",
+  "MajorBreakfast",
+  "Jfelix61",
+  "Flydiverny",
+  "FlickerLogicalStack",
+  "DmacMcgreg",
+  "Dattaya",
+  "Andrey-Bazhanov",
+  "AlvaroBernalG"
+]
diff --git a/docs/useAsyncFn.md b/docs/useAsyncFn.md
index cc4825fb..94cc243e 100644
--- a/docs/useAsyncFn.md
+++ b/docs/useAsyncFn.md
@@ -32,5 +32,5 @@ const Demo = ({url}) => {
 ## Reference
 
 ```ts
-useAsyncFn(fn, deps?: any[]);
+useAsyncFn<Result, Args>(fn, deps?: any[], initialState?: AsyncState<Result>);
 ```
diff --git a/docs/useLocalStorage.md b/docs/useLocalStorage.md
index 00574bf1..a00c68a6 100644
--- a/docs/useLocalStorage.md
+++ b/docs/useLocalStorage.md
@@ -8,13 +8,14 @@ React side-effect hook that manages a single `localStorage` key.
 import { useLocalStorage } from 'react-use';
 
 const Demo = () => {
-  const [value, setValue] = useLocalStorage('my-key', 'foo');
+  const [value, setValue, remove] = useLocalStorage('my-key', 'foo');
 
   return (
     <div>
       <div>Value: {value}</div>
       <button onClick={() => setValue('bar')}>bar</button>
       <button onClick={() => setValue('baz')}>baz</button>
+      <button onClick={() => remove()}>Remove</button>
     </div>
   );
 };
diff --git a/docs/useMeasure.md b/docs/useMeasure.md
index d8e87a0c..306fec3b 100644
--- a/docs/useMeasure.md
+++ b/docs/useMeasure.md
@@ -25,6 +25,21 @@ const Demo = () => {
 };
 ```
 
+This hook uses [`ResizeObserver` API][resize-observer], if you want to support 
+legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] 
+before running your app. 
+
+```js
+if (!window.ResizeObserver) {
+  window.ResizeObserver = (await import('resize-observer-polyfill')).default;
+}
+```
+
+
 ## Related hooks
 
 - [useSize](./useSize.md)
+
+
+[resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
+[resize-observer-polyfill]: https://www.npmjs.com/package/resize-observer-polyfill
diff --git a/package.json b/package.json
index 7971255e..a24a734e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-use",
-  "version": "14.3.0",
+  "version": "15.0.0-alpha.1",
   "description": "Collection of React Hooks",
   "main": "lib/index.js",
   "module": "esm/index.js",
@@ -169,7 +169,7 @@
       "<rootDir>/tests/**/*.test.(ts|tsx)"
     ],
     "setupFiles": [
-      "./tests/setupTests.ts"
+      "<rootDir>/tests/setupTests.ts"
     ]
   }
 }
diff --git a/src/useAsync.ts b/src/useAsync.ts
index e20b0b68..887326d9 100644
--- a/src/useAsync.ts
+++ b/src/useAsync.ts
@@ -1,13 +1,11 @@
 import { DependencyList, useEffect } from 'react';
 import useAsyncFn from './useAsyncFn';
+import { FnReturningPromise } from './util';
 
-export { AsyncState, AsyncFn } from './useAsyncFn';
+export { AsyncState, AsyncFnReturn } from './useAsyncFn';
 
-export default function useAsync<Result = any, Args extends any[] = any[]>(
-  fn: (...args: Args | []) => Promise<Result>,
-  deps: DependencyList = []
-) {
-  const [state, callback] = useAsyncFn<Result, Args>(fn, deps, {
+export default function useAsync<T extends FnReturningPromise>(fn: T, deps: DependencyList = []) {
+  const [state, callback] = useAsyncFn(fn, deps, {
     loading: true,
   });
 
diff --git a/src/useAsyncFn.ts b/src/useAsyncFn.ts
index cd931e5c..0ec39a52 100644
--- a/src/useAsyncFn.ts
+++ b/src/useAsyncFn.ts
@@ -1,6 +1,7 @@
 /* eslint-disable */
 import { DependencyList, useCallback, useState, useRef } from 'react';
 import useMountedState from './useMountedState';
+import { FnReturningPromise, PromiseType } from './util';
 
 export type AsyncState<T> =
   | {
@@ -8,6 +9,11 @@ export type AsyncState<T> =
       error?: undefined;
       value?: undefined;
     }
+  | {
+      loading: true;
+      error?: Error | undefined;
+      value?: T;
+    }
   | {
       loading: false;
       error: Error;
@@ -19,24 +25,22 @@ export type AsyncState<T> =
       value: T;
     };
 
-export type AsyncFn<Result = any, Args extends any[] = any[]> = [
-  AsyncState<Result>,
-  (...args: Args | []) => Promise<Result>
-];
+type StateFromFnReturningPromise<T extends FnReturningPromise> = AsyncState<PromiseType<ReturnType<T>>>;
 
-export default function useAsyncFn<Result = any, Args extends any[] = any[]>(
-  fn: (...args: Args | []) => Promise<Result>,
+export type AsyncFnReturn<T extends FnReturningPromise = FnReturningPromise> = [StateFromFnReturningPromise<T>, T];
+
+export default function useAsyncFn<T extends FnReturningPromise>(
+  fn: T,
   deps: DependencyList = [],
-  initialState: AsyncState<Result> = { loading: false }
-): AsyncFn<Result, Args> {
+  initialState: StateFromFnReturningPromise<T> = { loading: false }
+): AsyncFnReturn<T> {
   const lastCallId = useRef(0);
-  const [state, set] = useState<AsyncState<Result>>(initialState);
-
   const isMounted = useMountedState();
+  const [state, set] = useState<StateFromFnReturningPromise<T>>(initialState);
 
-  const callback = useCallback((...args: Args | []) => {
+  const callback = useCallback((...args: Parameters<T>): ReturnType<T> => {
     const callId = ++lastCallId.current;
-    set({ loading: true });
+    set(prevState => ({ ...prevState, loading: true }));
 
     return fn(...args).then(
       value => {
@@ -49,8 +53,8 @@ export default function useAsyncFn<Result = any, Args extends any[] = any[]>(
 
         return error;
       }
-    );
+    ) as ReturnType<T>;
   }, deps);
 
-  return [state, callback];
+  return [state, (callback as unknown) as T];
 }
diff --git a/src/useCopyToClipboard.ts b/src/useCopyToClipboard.ts
index ca29f714..e39f9c6d 100644
--- a/src/useCopyToClipboard.ts
+++ b/src/useCopyToClipboard.ts
@@ -19,31 +19,46 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] =
   });
 
   const copyToClipboard = useCallback(value => {
+    if (!isMounted()) {
+      return;
+    }
+    let noUserInteraction;
+    let normalizedValue;
     try {
-      if (process.env.NODE_ENV === 'development') {
-        if (typeof value !== 'string') {
-          console.error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`);
-        }
+      // only strings and numbers casted to strings can be copied to clipboard
+      if (typeof value !== 'string' && typeof value !== 'number') {
+        const error = new Error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`);
+        if (process.env.NODE_ENV === 'development') console.error(error);
+        setState({
+          value,
+          error,
+          noUserInteraction: true,
+        });
+        return;
       }
-
-      const noUserInteraction = writeText(value);
-
-      if (!isMounted()) {
+      // empty strings are also considered invalid
+      else if (value === '') {
+        const error = new Error(`Cannot copy empty string to clipboard.`);
+        if (process.env.NODE_ENV === 'development') console.error(error);
+        setState({
+          value,
+          error,
+          noUserInteraction: true,
+        });
         return;
       }
+      normalizedValue = value.toString();
+      noUserInteraction = writeText(normalizedValue);
       setState({
-        value,
+        value: normalizedValue,
         error: undefined,
         noUserInteraction,
       });
     } catch (error) {
-      if (!isMounted()) {
-        return;
-      }
       setState({
-        value: undefined,
+        value: normalizedValue,
         error,
-        noUserInteraction: true,
+        noUserInteraction,
       });
     }
   }, []);
diff --git a/src/useCustomCompareEffect.ts b/src/useCustomCompareEffect.ts
index 8f189188..b5c73142 100644
--- a/src/useCustomCompareEffect.ts
+++ b/src/useCustomCompareEffect.ts
@@ -2,9 +2,13 @@ import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
 
 const isPrimitive = (val: any) => val !== Object(val);
 
-type DepsEqualFnType = (prevDeps: DependencyList, nextDeps: DependencyList) => boolean;
+type DepsEqualFnType<TDeps extends DependencyList> = (prevDeps: TDeps, nextDeps: TDeps) => boolean;
 
-const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, depsEqual: DepsEqualFnType) => {
+const useCustomCompareEffect = <TDeps extends DependencyList>(
+  effect: EffectCallback,
+  deps: TDeps,
+  depsEqual: DepsEqualFnType<TDeps>
+) => {
   if (process.env.NODE_ENV !== 'production') {
     if (!(deps instanceof Array) || !deps.length) {
       console.warn('`useCustomCompareEffect` should not be used with no dependencies. Use React.useEffect instead.');
@@ -21,7 +25,7 @@ const useCustomCompareEffect = (effect: EffectCallback, deps: DependencyList, de
     }
   }
 
-  const ref = useRef<DependencyList | undefined>(undefined);
+  const ref = useRef<TDeps | undefined>(undefined);
 
   if (!ref.current || !depsEqual(deps, ref.current)) {
     ref.current = deps;
diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts
index 698de2ec..2044e3f6 100644
--- a/src/useLocalStorage.ts
+++ b/src/useLocalStorage.ts
@@ -1,5 +1,5 @@
 /* eslint-disable */
-import { useEffect, useState } from 'react';
+import { useState, useCallback, Dispatch, SetStateAction } from 'react';
 import { isClient } from './util';
 
 type parserOptions<T> =
@@ -12,21 +12,26 @@ type parserOptions<T> =
       deserializer: (value: string) => T;
     };
 
+const noop = () => {};
+
 const useLocalStorage = <T>(
   key: string,
   initialValue?: T,
   options?: parserOptions<T>
-): [T, React.Dispatch<React.SetStateAction<T>>] => {
+): [T | undefined, Dispatch<SetStateAction<T | undefined>>, () => void] => {
   if (!isClient) {
-    return [initialValue as T, () => {}];
+    return [initialValue as T, noop, noop];
+  }
+  if (!key) {
+    throw new Error('useLocalStorage key may not be falsy');
   }
 
-  // Use provided serializer/deserializer or the default ones
-  const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify;
-  const deserializer = options ? (options.raw ? String : options.deserializer) : JSON.parse;
+  const deserializer = options ? (options.raw ? value => value : options.deserializer) : JSON.parse;
 
-  const [state, setState] = useState<T>(() => {
+  const [state, setState] = useState<T | undefined>(() => {
     try {
+      const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify;
+
       const localStorageValue = localStorage.getItem(key);
       if (localStorageValue !== null) {
         return deserializer(localStorageValue);
@@ -42,16 +47,42 @@ const useLocalStorage = <T>(
     }
   });
 
-  useEffect(() => {
+  const set: Dispatch<SetStateAction<T | undefined>> = useCallback(
+    valOrFunc => {
+      try {
+        const newState = typeof valOrFunc === 'function' ? (valOrFunc as Function)(state) : valOrFunc;
+        if (typeof newState === 'undefined') return;
+        let value: string;
+
+        if (options)
+          if (options.raw)
+            if (typeof newState === 'string') value = newState;
+            else value = JSON.stringify(newState);
+          else if (options.serializer) value = options.serializer(newState);
+          else value = JSON.stringify(newState);
+        else value = JSON.stringify(newState);
+
+        localStorage.setItem(key, value);
+        setState(deserializer(value));
+      } catch {
+        // If user is in private mode or has storage restriction
+        // localStorage can throw. Also JSON.stringify can throw.
+      }
+    },
+    [key, setState]
+  );
+
+  const remove = useCallback(() => {
     try {
-      localStorage.setItem(key, serializer(state));
+      localStorage.removeItem(key);
+      setState(undefined);
     } catch {
       // If user is in private mode or has storage restriction
-      // localStorage can throw. Also JSON.stringify can throw.
+      // localStorage can throw.
     }
-  }, [state]);
+  }, [key, setState]);
 
-  return [state, setState];
+  return [state, set, remove];
 };
 
 export default useLocalStorage;
diff --git a/src/useMeasure.ts b/src/useMeasure.ts
index 4622355f..a5bee4aa 100644
--- a/src/useMeasure.ts
+++ b/src/useMeasure.ts
@@ -1,40 +1,51 @@
-import { useCallback, useState } from 'react';
-import ResizeObserver from 'resize-observer-polyfill';
-
-export type ContentRect = Pick<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>;
-
-const useMeasure = <T>(): [(instance: T) => void, ContentRect] => {
-  const [rect, set] = useState<ContentRect>({
-    x: 0,
-    y: 0,
-    width: 0,
-    height: 0,
-    top: 0,
-    left: 0,
-    bottom: 0,
-    right: 0,
-  });
-
-  const [observer] = useState(
+import { useState, useMemo } from 'react';
+import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
+import { isClient } from './util';
+
+export type UseMeasureRect = Pick<
+  DOMRectReadOnly,
+  'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'
+>;
+export type UseMeasureRef = (element: HTMLElement) => void;
+export type UseMeasureResult = [UseMeasureRef, UseMeasureRect];
+
+const defaultState: UseMeasureRect = {
+  x: 0,
+  y: 0,
+  width: 0,
+  height: 0,
+  top: 0,
+  left: 0,
+  bottom: 0,
+  right: 0,
+};
+
+const useMeasure = (): UseMeasureResult => {
+  const [element, ref] = useState<HTMLElement | null>(null);
+  const [rect, setRect] = useState<UseMeasureRect>(defaultState);
+
+  const observer = useMemo(
     () =>
-      new ResizeObserver(entries => {
-        const entry = entries[0];
-        if (entry) {
-          set(entry.contentRect);
+      new (window as any).ResizeObserver(entries => {
+        if (entries[0]) {
+          const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect;
+          setRect({ x, y, width, height, top, left, bottom, right });
         }
-      })
+      }),
+    []
   );
 
-  const ref = useCallback(
-    node => {
+  useIsomorphicLayoutEffect(() => {
+    if (!element) return;
+    observer.observe(element);
+    return () => {
       observer.disconnect();
-      if (node) {
-        observer.observe(node);
-      }
-    },
-    [observer]
-  );
+    };
+  }, [element]);
+
   return [ref, rect];
 };
 
-export default useMeasure;
+const useMeasureMock = () => [() => {}, defaultState];
+
+export default (isClient && !!(window as any).ResizeObserver) ? useMeasure : useMeasureMock;
diff --git a/src/useUpdate.ts b/src/useUpdate.ts
index d7f0d368..bcc4511a 100644
--- a/src/useUpdate.ts
+++ b/src/useUpdate.ts
@@ -1,11 +1,10 @@
-import { useCallback, useState } from 'react';
+import { useReducer } from 'react';
 
-const incrementParameter = (num: number): number => ++num % 1_000_000;
+const updateReducer = (num: number): number => (num + 1) % 1_000_000;
 
 const useUpdate = () => {
-  const [, setState] = useState(0);
-  // useCallback with empty deps as we only want to define updateCb once
-  return useCallback(() => setState(incrementParameter), []);
+  const [, update] = useReducer(updateReducer, 0);
+  return update as () => void;
 };
 
 export default useUpdate;
diff --git a/src/util.ts b/src/util.ts
index 7a930a4e..99f72b21 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -4,4 +4,8 @@ export const on = (obj: any, ...args: any[]) => obj.addEventListener(...args);
 
 export const off = (obj: any, ...args: any[]) => obj.removeEventListener(...args);
 
+export type FnReturningPromise = (...args: any[]) => Promise<any>;
+
+export type PromiseType<P extends Promise<any>> = P extends Promise<infer T> ? T : never;
+
 export const isDeepEqual: (a: any, b: any) => boolean = require('fast-deep-equal/react');
diff --git a/stories/useLocalStorage.story.tsx b/stories/useLocalStorage.story.tsx
index 31f8e870..f246c960 100644
--- a/stories/useLocalStorage.story.tsx
+++ b/stories/useLocalStorage.story.tsx
@@ -5,12 +5,19 @@ import ShowDocs from './util/ShowDocs';
 
 const Demo = () => {
   const [value, setValue] = useLocalStorage('hello-key', 'foo');
+  const [removableValue, setRemovableValue, remove] = useLocalStorage('removeable-key');
 
   return (
     <div>
       <div>Value: {value}</div>
       <button onClick={() => setValue('bar')}>bar</button>
       <button onClick={() => setValue('baz')}>baz</button>
+      <br />
+      <br />
+      <div>Removable Value: {removableValue}</div>
+      <button onClick={() => setRemovableValue('foo')}>foo</button>
+      <button onClick={() => setRemovableValue('bar')}>bar</button>
+      <button onClick={() => remove()}>Remove</button>
     </div>
   );
 };


Test patch:
diff --git a/tests/setupTests.ts b/tests/setupTests.ts
index 1c787210..c0ced93c 100644
--- a/tests/setupTests.ts
+++ b/tests/setupTests.ts
@@ -1,1 +1,6 @@
 import 'jest-localstorage-mock';
+
+(window as any).ResizeObserver = class ResizeObserver {
+  observe() {}
+  disconnect() {}
+};
diff --git a/tests/useAsync.test.tsx b/tests/useAsync.test.tsx
index 21145a3e..5373a7fb 100644
--- a/tests/useAsync.test.tsx
+++ b/tests/useAsync.test.tsx
@@ -148,7 +148,7 @@ describe('useAsync', () => {
         hook = renderHook(
           ({ fn, counter }) => {
             const callback = useCallback(() => fn(counter), [counter]);
-            return useAsync<string>(callback, [callback]);
+            return useAsync<any>(callback, [callback]);
           },
           {
             initialProps: {
diff --git a/tests/useAsyncFn.test.tsx b/tests/useAsyncFn.test.tsx
index 63ca25b2..4dcebfc3 100644
--- a/tests/useAsyncFn.test.tsx
+++ b/tests/useAsyncFn.test.tsx
@@ -126,4 +126,31 @@ describe('useAsyncFn', () => {
     await hook.waitForNextUpdate();
     expect(hook.result.current[0]).toEqual({ loading: false, value: 2 });
   });
+
+  it('should keeping value of initialState when loading', async () => {
+    const fetch = async () => 'new state';
+    const initialState = { loading: false, value: 'init state' };
+
+    const hook = renderHook<{ fn: () => Promise<string> }, [AsyncState<string>, () => Promise<string>]>(
+      ({ fn }) => useAsyncFn(fn, [fn], initialState),
+      {
+        initialProps: { fn: fetch },
+      }
+    );
+
+    const [state, callback] = hook.result.current;
+    expect(state.loading).toBe(false);
+    expect(state.value).toBe('init state');
+
+    act(() => {
+      callback();
+    });
+
+    expect(hook.result.current[0].loading).toBe(true);
+    expect(hook.result.current[0].value).toBe('init state');
+
+    await hook.waitForNextUpdate();
+    expect(hook.result.current[0].loading).toBe(false);
+    expect(hook.result.current[0].value).toBe('new state');
+  });
 });
diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts
new file mode 100644
index 00000000..fd8de8eb
--- /dev/null
+++ b/tests/useCopyToClipboard.test.ts
@@ -0,0 +1,103 @@
+import writeText from 'copy-to-clipboard';
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useCopyToClipboard } from '../src';
+
+const valueToRaiseMockException = 'fake input causing exception in copy to clipboard';
+
+jest.mock('copy-to-clipboard', () =>
+  jest.fn().mockImplementation(input => {
+    if (input === valueToRaiseMockException) {
+      throw new Error(input);
+    }
+    return true;
+  })
+);
+jest.spyOn(global.console, 'error').mockImplementation(() => {});
+
+describe('useCopyToClipboard', () => {
+  let hook;
+
+  beforeEach(() => {
+    hook = renderHook(() => useCopyToClipboard());
+  });
+
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('should be defined ', () => {
+    expect(useCopyToClipboard).toBeDefined();
+  });
+
+  it('should pass a given value to copy to clipboard and set state', () => {
+    const testValue = 'test';
+    let [state, copyToClipboard] = hook.result.current;
+    act(() => copyToClipboard(testValue));
+    [state, copyToClipboard] = hook.result.current;
+
+    expect(writeText).toBeCalled();
+    expect(state.value).toBe(testValue);
+    expect(state.noUserInteraction).toBe(true);
+    expect(state.error).not.toBeDefined();
+  });
+
+  it('should not call writeText if passed an invalid input and set state', () => {
+    let testValue = {}; // invalid value
+    let [state, copyToClipboard] = hook.result.current;
+    act(() => copyToClipboard(testValue));
+    [state, copyToClipboard] = hook.result.current;
+
+    expect(writeText).not.toBeCalled();
+    expect(state.value).toBe(testValue);
+    expect(state.noUserInteraction).toBe(true);
+    expect(state.error).toBeDefined();
+
+    testValue = ''; // emtpy string is also invalid
+    act(() => copyToClipboard(testValue));
+    [state, copyToClipboard] = hook.result.current;
+
+    expect(writeText).not.toBeCalled();
+    expect(state.value).toBe(testValue);
+    expect(state.noUserInteraction).toBe(true);
+    expect(state.error).toBeDefined();
+  });
+
+  it('should catch exception thrown by copy-to-clipboard and set state', () => {
+    let [state, copyToClipboard] = hook.result.current;
+    act(() => copyToClipboard(valueToRaiseMockException));
+    [state, copyToClipboard] = hook.result.current;
+
+    expect(writeText).toBeCalledWith(valueToRaiseMockException);
+    expect(state.value).toBe(valueToRaiseMockException);
+    expect(state.noUserInteraction).not.toBeDefined();
+    expect(state.error).toStrictEqual(new Error(valueToRaiseMockException));
+  });
+
+  it('should return initial state while unmounted', () => {
+    hook.unmount();
+    const [state, copyToClipboard] = hook.result.current;
+
+    act(() => copyToClipboard('value'));
+    expect(state.value).not.toBeDefined();
+    expect(state.error).not.toBeDefined();
+    expect(state.noUserInteraction).toBe(true);
+  });
+
+  it('should console error if in dev environment', () => {
+    const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
+    const testValue = {}; // invalid value
+
+    process.env.NODE_ENV = 'development';
+    let [state, copyToClipboard] = hook.result.current;
+    act(() => copyToClipboard(testValue));
+    process.env.NODE_ENV = ORIGINAL_NODE_ENV;
+
+    [state, copyToClipboard] = hook.result.current;
+
+    expect(writeText).not.toBeCalled();
+    expect(console.error).toBeCalled();
+    expect(state.value).toBe(testValue);
+    expect(state.noUserInteraction).toBe(true);
+    expect(state.error).toBeDefined();
+  });
+});
diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts
index 95340c54..da01dc86 100644
--- a/tests/useLocalStorage.test.ts
+++ b/tests/useLocalStorage.test.ts
@@ -1,95 +1,237 @@
+/* eslint-disable */
+import useLocalStorage from '../src/useLocalStorage';
+import 'jest-localstorage-mock';
 import { renderHook, act } from '@testing-library/react-hooks';
-import { useLocalStorage } from '../src';
 
-const STRINGIFIED_VALUE = '{"a":"b"}';
-const JSONIFIED_VALUE = { a: 'b' };
+describe(useLocalStorage, () => {
+  afterEach(() => {
+    localStorage.clear();
+    jest.clearAllMocks();
+  });
 
-afterEach(() => {
-  localStorage.clear();
-  jest.clearAllMocks();
-});
+  it('retrieves an existing value from localStorage', () => {
+    localStorage.setItem('foo', '"bar"');
+    const { result } = renderHook(() => useLocalStorage('foo'));
+    const [state] = result.current;
+    expect(state).toEqual('bar');
+  });
 
-it('should return undefined if no initialValue provided and localStorage empty', () => {
-  const { result } = renderHook(() => useLocalStorage('some_key'));
+  it('should return initialValue if localStorage empty and set that to localStorage', () => {
+    const { result } = renderHook(() => useLocalStorage('foo', 'bar'));
+    const [state] = result.current;
+    expect(state).toEqual('bar');
+    expect(localStorage.__STORE__.foo).toEqual('"bar"');
+  });
 
-  expect(result.current[0]).toBeUndefined();
-});
+  it('prefers existing value over initial state', () => {
+    localStorage.setItem('foo', '"bar"');
+    const { result } = renderHook(() => useLocalStorage('foo', 'baz'));
+    const [state] = result.current;
+    expect(state).toEqual('bar');
+  });
 
-it('should set the value from existing localStorage key', () => {
-  const key = 'some_key';
-  localStorage.setItem(key, STRINGIFIED_VALUE);
+  it('does not clobber existing localStorage with initialState', () => {
+    localStorage.setItem('foo', '"bar"');
+    const { result } = renderHook(() => useLocalStorage('foo', 'buzz'));
+    result.current; // invoke current to make sure things are set
+    expect(localStorage.__STORE__.foo).toEqual('"bar"');
+  });
 
-  const { result } = renderHook(() => useLocalStorage(key));
+  it('correctly updates localStorage', () => {
+    const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar'));
 
-  expect(result.current[0]).toEqual(JSONIFIED_VALUE);
-});
+    const [, setFoo] = result.current;
+    act(() => setFoo('baz'));
+    rerender();
 
-it('should return initialValue if localStorage empty and set that to localStorage', () => {
-  const key = 'some_key';
-  const value = 'some_value';
+    expect(localStorage.__STORE__.foo).toEqual('"baz"');
+  });
 
-  const { result } = renderHook(() => useLocalStorage(key, value));
+  it('should return undefined if no initialValue provided and localStorage empty', () => {
+    const { result } = renderHook(() => useLocalStorage('some_key'));
 
-  expect(result.current[0]).toBe(value);
-  expect(localStorage.__STORE__[key]).toBe(`"${value}"`);
-});
+    expect(result.current[0]).toBeUndefined();
+  });
 
-it('should return the value from localStorage if exists even if initialValue provied', () => {
-  const key = 'some_key';
-  localStorage.setItem(key, STRINGIFIED_VALUE);
+  it('returns and allow setting null', () => {
+    localStorage.setItem('foo', 'null');
+    const { result, rerender } = renderHook(() => useLocalStorage('foo'));
 
-  const { result } = renderHook(() => useLocalStorage(key, 'random_value'));
+    const [foo1, setFoo] = result.current;
+    act(() => setFoo(null));
+    rerender();
 
-  expect(result.current[0]).toEqual(JSONIFIED_VALUE);
-});
+    const [foo2] = result.current;
+    expect(foo1).toEqual(null);
+    expect(foo2).toEqual(null);
+  });
 
-it('should properly update the localStorage on change', () => {
-  const key = 'some_key';
-  const updatedValue = { b: 'a' };
-  const expectedValue = '{"b":"a"}';
+  it('sets initialState if initialState is an object', () => {
+    renderHook(() => useLocalStorage('foo', { bar: true }));
+    expect(localStorage.__STORE__.foo).toEqual('{"bar":true}');
+  });
+
+  it('correctly and promptly returns a new value', () => {
+    const { result, rerender } = renderHook(() => useLocalStorage('foo', 'bar'));
 
-  const { result } = renderHook(() => useLocalStorage(key));
+    const [, setFoo] = result.current;
+    act(() => setFoo('baz'));
+    rerender();
 
-  act(() => {
-    result.current[1](updatedValue);
+    const [foo] = result.current;
+    expect(foo).toEqual('baz');
   });
 
-  expect(result.current[0]).toBe(updatedValue);
-  expect(localStorage.__STORE__[key]).toBe(expectedValue);
-});
+  /*
+  it('keeps multiple hooks accessing the same key in sync', () => {
+    localStorage.setItem('foo', 'bar');
+    const { result: r1, rerender: rerender1 } = renderHook(() => useLocalStorage('foo'));
+    const { result: r2, rerender: rerender2 } = renderHook(() => useLocalStorage('foo'));
 
-describe('Options with raw true', () => {
-  it('should set the value from existing localStorage key', () => {
-    const key = 'some_key';
-    localStorage.setItem(key, STRINGIFIED_VALUE);
+    const [, setFoo] = r1.current;
+    act(() => setFoo('potato'));
+    rerender1();
+    rerender2();
 
-    const { result } = renderHook(() => useLocalStorage(key, '', { raw: true }));
+    const [val1] = r1.current;
+    const [val2] = r2.current;
 
-    expect(result.current[0]).toEqual(STRINGIFIED_VALUE);
+    expect(val1).toEqual(val2);
+    expect(val1).toEqual('potato');
+    expect(val2).toEqual('potato');
   });
+  */
 
-  it('should return initialValue if localStorage empty and set that to localStorage', () => {
-    const key = 'some_key';
+  it('parses out objects from localStorage', () => {
+    localStorage.setItem('foo', JSON.stringify({ ok: true }));
+    const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>('foo'));
+    const [foo] = result.current;
+    expect(foo!.ok).toEqual(true);
+  });
+
+  it('safely initializes objects to localStorage', () => {
+    const { result } = renderHook(() => useLocalStorage<{ ok: boolean }>('foo', { ok: true }));
+    const [foo] = result.current;
+    expect(foo!.ok).toEqual(true);
+  });
+
+  it('safely sets objects to localStorage', () => {
+    const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true }));
 
-    const { result } = renderHook(() => useLocalStorage(key, STRINGIFIED_VALUE, { raw: true }));
+    const [, setFoo] = result.current;
+    act(() => setFoo({ ok: 'bar' }));
+    rerender();
 
-    expect(result.current[0]).toBe(STRINGIFIED_VALUE);
-    expect(localStorage.__STORE__[key]).toBe(STRINGIFIED_VALUE);
+    const [foo] = result.current;
+    expect(foo!.ok).toEqual('bar');
   });
-});
 
-describe('Options with raw false and provided serializer/deserializer', () => {
-  const serializer = (_: string) => '321';
-  const deserializer = (_: string) => '123';
+  it('safely returns objects from updates', () => {
+    const { result, rerender } = renderHook(() => useLocalStorage<{ ok: any }>('foo', { ok: true }));
+
+    const [, setFoo] = result.current;
+    act(() => setFoo({ ok: 'bar' }));
+    rerender();
 
-  it('should return valid serialized value from existing localStorage key', () => {
-    const key = 'some_key';
-    localStorage.setItem(key, STRINGIFIED_VALUE);
+    const [foo] = result.current;
+    expect(foo).toBeInstanceOf(Object);
+    expect(foo!.ok).toEqual('bar');
+  });
 
-    const { result } = renderHook(() =>
-      useLocalStorage(key, STRINGIFIED_VALUE, { raw: false, serializer, deserializer })
+  it('sets localStorage from the function updater', () => {
+    const { result, rerender } = renderHook(() =>
+      useLocalStorage<{ foo: string; fizz?: string }>('foo', { foo: 'bar' })
     );
 
-    expect(result.current[0]).toBe('123');
+    const [, setFoo] = result.current;
+    act(() => setFoo(state => ({ ...state!, fizz: 'buzz' })));
+    rerender();
+
+    const [value] = result.current;
+    expect(value!.foo).toEqual('bar');
+    expect(value!.fizz).toEqual('buzz');
+  });
+
+  it('rejects nullish or undefined keys', () => {
+    const { result } = renderHook(() => useLocalStorage(null as any));
+    try {
+      result.current;
+      fail('hook should have thrown');
+    } catch (e) {
+      expect(String(e)).toMatch(/key may not be/i);
+    }
+  });
+
+  /* Enforces proper eslint react-hooks/rules-of-hooks usage */
+  describe('eslint react-hooks/rules-of-hooks', () => {
+    it('memoizes an object between rerenders', () => {
+      const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true }));
+
+      result.current; // if localStorage isn't set then r1 and r2 will be different
+      rerender();
+      const [r2] = result.current;
+      rerender();
+      const [r3] = result.current;
+      expect(r2).toBe(r3);
+    });
+
+    it('memoizes an object immediately if localStorage is already set', () => {
+      localStorage.setItem('foo', JSON.stringify({ ok: true }));
+      const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true }));
+
+      const [r1] = result.current; // if localStorage isn't set then r1 and r2 will be different
+      rerender();
+      const [r2] = result.current;
+      expect(r1).toBe(r2);
+    });
+
+    it('memoizes the setState function', () => {
+      localStorage.setItem('foo', JSON.stringify({ ok: true }));
+      const { result, rerender } = renderHook(() => useLocalStorage('foo', { ok: true }));
+      const [, s1] = result.current;
+      rerender();
+      const [, s2] = result.current;
+      expect(s1).toBe(s2);
+    });
+  });
+
+  describe('Options: raw', () => {
+    it('returns a string when localStorage is a stringified object', () => {
+      localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
+      const { result } = renderHook(() => useLocalStorage('foo', null, { raw: true }));
+      const [foo] = result.current;
+      expect(typeof foo).toBe('string');
+    });
+
+    it('returns a string after an update', () => {
+      localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
+      const { result, rerender } = renderHook(() => useLocalStorage('foo', null, { raw: true }));
+
+      const [, setFoo] = result.current;
+
+      act(() => setFoo({ fizz: 'bang' } as any));
+      rerender();
+
+      const [foo] = result.current;
+      expect(typeof foo).toBe('string');
+
+      expect(JSON.parse(foo!)).toBeInstanceOf(Object);
+
+      // expect(JSON.parse(foo!).fizz).toEqual('bang');
+    });
+
+    it('still forces setState to a string', () => {
+      localStorage.setItem('foo', JSON.stringify({ fizz: 'buzz' }));
+      const { result, rerender } = renderHook(() => useLocalStorage('foo', null, { raw: true }));
+
+      const [, setFoo] = result.current;
+
+      act(() => setFoo({ fizz: 'bang' } as any));
+      rerender();
+
+      const [value] = result.current;
+
+      expect(JSON.parse(value!).fizz).toEqual('bang');
+    });
   });
 });
diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts
index a242865b..94c83b6e 100644
--- a/tests/useMeasure.test.ts
+++ b/tests/useMeasure.test.ts
@@ -1,78 +1,14 @@
-/* eslint-disable */
-import { act, renderHook } from '@testing-library/react-hooks';
-import useMeasure, { ContentRect } from '../src/useMeasure';
-
-interface Entry {
-  target: HTMLElement;
-  contentRect: ContentRect;
-}
-
-jest.mock('resize-observer-polyfill', () => {
-  return class ResizeObserver {
-    private cb: (entries: Entry[]) => void;
-    private map: WeakMap<HTMLElement, any>;
-    private targets: HTMLElement[];
-    constructor(cb: () => void) {
-      this.cb = cb;
-      this.map = new WeakMap();
-      this.targets = [];
-    }
-    public disconnect() {
-      this.targets.map(target => {
-        const originMethod = this.map.get(target);
-        target.setAttribute = originMethod;
-        this.map.delete(target);
-      });
-    }
-    public observe(target: HTMLElement) {
-      const method = 'setAttribute';
-      const originMethod = target[method];
-      this.map.set(target, originMethod);
-      this.targets.push(target);
-      target[method] = (...args) => {
-        const [attrName, value] = args;
-        if (attrName === 'style') {
-          const rect: DOMRectReadOnly = {
-            x: 0,
-            y: 0,
-            top: 0,
-            left: 0,
-            right: 0,
-            bottom: 0,
-            width: 0,
-            height: 0,
-          } as DOMRectReadOnly;
-          value.split(';').map(kv => {
-            const [key, v] = kv.split(':');
-            if (['top', 'bottom', 'left', 'right', 'width', 'height'].includes(key)) {
-              rect[key] = parseInt(v, 10);
-            }
-          });
-          target.getBoundingClientRect = () => rect;
-        }
-        originMethod.apply(target, args);
-        this.fireCallback();
-      };
-    }
-    private fireCallback() {
-      if (this.cb) {
-        this.cb(
-          this.targets.map(target => {
-            return {
-              target,
-              contentRect: target.getBoundingClientRect() as ContentRect,
-            };
-          })
-        );
-      }
-    }
-  };
-});
+import { renderHook, act } from '@testing-library/react-hooks';
+import useMeasure, { UseMeasureRef } from '../src/useMeasure';
 
-it('reacts to changes in size of any of the observed elements', () => {
+it('by default, state defaults every value to -1', () => {
   const { result } = renderHook(() => useMeasure());
-  const div = document.createElement('div');
-  result.current[0](div);
+
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
   expect(result.current[1]).toMatchObject({
     width: 0,
     height: 0,
@@ -81,9 +17,63 @@ it('reacts to changes in size of any of the observed elements', () => {
     left: 0,
     right: 0,
   });
-  act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100'));
+});
+
+it('synchronously sets up ResizeObserver listener', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
+  expect(typeof listener).toBe('function');
+});
+
+it('tracks rectangle of a DOM element', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 1,
+        y: 2,
+        width: 200,
+        height: 200,
+        top: 100,
+        bottom: 0,
+        left: 100,
+        right: 0,
+      }
+    }]);
+  });
 
   expect(result.current[1]).toMatchObject({
+    x: 1,
+    y: 2,
     width: 200,
     height: 200,
     top: 100,
@@ -92,3 +82,96 @@ it('reacts to changes in size of any of the observed elements', () => {
     right: 0,
   });
 });
+
+it('tracks multiple updates', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 1,
+        y: 1,
+        width: 1,
+        height: 1,
+        top: 1,
+        bottom: 1,
+        left: 1,
+        right: 1,
+      }
+    }]);
+  });
+
+  expect(result.current[1]).toMatchObject({
+    x: 1,
+    y: 1,
+    width: 1,
+    height: 1,
+    top: 1,
+    bottom: 1,
+    left: 1,
+    right: 1,
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 2,
+        y: 2,
+        width: 2,
+        height: 2,
+        top: 2,
+        bottom: 2,
+        left: 2,
+        right: 2,
+      }
+    }]);
+  });
+
+  expect(result.current[1]).toMatchObject({
+    x: 2,
+    y: 2,
+    width: 2,
+    height: 2,
+    top: 2,
+    bottom: 2,
+    left: 2,
+    right: 2,
+  });
+});
+
+it('calls .disconnect() on ResizeObserver when component unmounts', () => {
+  const disconnect = jest.fn();
+  (window as any).ResizeObserver = class ResizeObserver {
+    observe() {}
+    disconnect() {
+      disconnect();
+    }
+  };
+
+  const { result, unmount } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
+  expect(disconnect).toHaveBeenCalledTimes(0);
+
+  unmount();
+
+  expect(disconnect).toHaveBeenCalledTimes(1);
+});