Skip to content

Latest commit

 

History

History
347 lines (275 loc) · 7.59 KB

recipes.md

File metadata and controls

347 lines (275 loc) · 7.59 KB
id title sidebar_label
recipes
Recipes
Recipes

React Tracked provides a primitive API, and there are various ways to use it for apps.

Recipes for createContainer

The argument useValue in createContainer is so flexible and there are various usages.

useReducer (props)

This is the most typical usage. You define a generic reducer and pass reducer and initialState as props.

const {
  Provider,
  useTracked,
  // ...
} = createContainer(({ reducer, initialState, init }) => useReducer(reducer, initialState, init));

const reducer = ...;

const App = ({ initialState }) => (
  <Provider reducer={reducer} initialState={initialState}>
    ...
  </Provider>
);

useReducer (embedded)

For most cases, you would have a static reducer. In this case, define useValue with the reducer in advance. The initialState can be defined in useValue like the following example, or can be taken from props: ({ initialState }) => useReducer(...)

This is good for TypeScript because the hooks returned by createContainer is already typed.

const reducer = ...;
const initialState = ...;

const {
  Provider,
  useTracked,
  // ...
} = createContainer(() => useReducer(reducer, initialState));


const App = () => (
  <Provider>
    ...
  </Provider>
);

useState (props)

If you don't need reducer, useState would be simpler.

const {
  Provider,
  useTracked,
  // ...
} = createContainer(({ initialState }) => useState(initialState);


const App = ({ initialState }) => (
  <Provider initialState={initialState}>
    ...
  </Provider>
);

useState (empty object)

You could even start with completely an empty object.

This might not be TypeScript friendly. Although, you could do this: useState<State>({})

const {
  Provider,
  useTracked,
  // ...
} = createContainer(() => useState({});

const App = () => (
  <Provider>
    ...
  </Provider>
);

useReducer (with persistence)

Here's how to persist state in localStorage.

const reducer = ...;
const initialState = ...; // used only if localStorage is empty.
const storageKey = 'persistedState';

const init = () => {
  let preloadedState;
  try {
    preloadedState =  JSON.parse(window.localStorage.getItem(storageKey));
    // validate preloadedState if necessary
  } catch (e) {
    // ignore
  }
  return preloadedState || initialState;
};

const useValue = () => {
  const [state, dispatch] = useReducer(reducer, null, init);
  useEffect(() => {
    window.localStorage.setItem(storageKey, JSON.stringify(state));
  }, [state]);
  return [state, dispatch];
};

const {
  Provider,
  useTracked,
  // ...
} = createContainer(useValue);

const App = () => (
  <Provider>
    ...
  </Provider>
);

Using async storage is a bit tricky. See the thread for an example.

useState (with propState)

If you already have a state and would like to use Provider with it, you can sync a container state with a state from props.

const useValue = ({ propState }) => {
  const [state, setState] = useState(propState);
  useEffect(() => {
    // or useLayoutEffect
    setState(propState);
  }, [propState]);
  return [state, setState];
};

const {
  Provider,
  useTracked,
  // ...
} = createContainer(useValue);

const App = ({ propState }) => <Provider propState={propState}>...</Provider>;

Note that propState has to be updated immutably.

useReducer (with event listener)

Here's how to dispatch actions by DOM events.

const reducer = ...;
const initialState = ...;

const useValue = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  useEffect(() => {
    const listener = () => {
      dispatch({
        type: 'WINDOW_RESIZED',
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };
    window.addEventListener('resize', listener);
    return () => {
      window.removeEventListener('resize', listener);
    };
  }, []);
  return [state, dispatch];
};

const {
  Provider,
  useTracked,
  // ...
} = createContainer(useValue);

const App = () => (
  <Provider>
    ...
  </Provider>
);

useState (with update functions)

If you want to have custom update functions, you can store them in a state object. Be sure to use useCallback and useMemo to make the state object stable.

const useValue = () => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const state = useMemo(
    () => ({
      count,
      increment,
      decrement,
    }),
    [count, increment, decrement],
  );
  return [
    state,
    () => {
      throw new Error('use functions in the state');
    },
  ];
};

const { Provider, useTrackedState } = createContainer(useValue);

const App = () => <Provider>...</Provider>;

Note: With custom update functions, you don't get the benefit even if you enable concurrentMode in createContainer.

Recipes for useTrackedState and useTracked

The useTrackedState and useTracked hooks are useful as is, but new hooks can also be created based on them.

useSelectorWithTracking

Selector interface is useful to share selection logic. You can create a selector hook with state usage tracking very easily.

const useSelectorWithTracking = (selector) => selector(useTrackedState());

Note: This is different from useSelector which has no tracking support and triggers re-render based on the ref equality of selected value.

useTrackedByName (based on useState)

Sometimes, you might want to select a state by its property name. Here's a custom hook to return a tuple [value, setValue] selected by a name.

const useTrackedByName = (name) => {
  const [state, setState] = useTracked();
  const update = useCallback(
    (newVal) => {
      setState((oldVal) => ({
        ...oldVal,
        [name]: typeof newVal === 'function' ? newVal(oldVal[name]) : newVal,
      }));
    },
    [setState, name],
  );
  return [state[name], update];
};

useTrackedWithImmer (based on useState)

Updating a property deep in a state object is troublesome. Here's a custom hook to use immer for setState.

import produce from 'immer';

const useTrackedWithImmer = () => {
  const [state, setState] = useTracked();
  const update = useCallback(
    (updater) => {
      setState((oldVal) => produce(oldVal, updater));
    },
    [setState],
  );
  return [state, update];
};

Note: This can also be done at createContainer.

Recipes for useUpdate (useDispatch)

The useUpdate simply returns the second item in a tuple returned by useState or useReducer. It can also be extended as a custom hook.

useSafeDispatch

This is a modified version of useDispatch that calls getUntrackedObject recursively on an action object before dispatching it.

import { getUntrackedObject } from 'react-tracked';

const untrackDeep = (obj) => {
  if (typeof obj !== 'object' || obj === null) return obj;
  const untrackedObj = getUntrackedObject(obj);
  if (untrackedObj !== null) return untrackedObj;
  const newObj = {};
  let modified = false;
  Object.entries(obj).forEach(([k, v]) => {
    newObj[k] = untrackDeep(v);
    if (newObj[k] !== null) {
      modified = true;
    } else {
      newObj[k] = v;
    }
  });
  return modified ? newObj : obj;
};

const useSafeDispatch = () => {
  const dispatch = useDispatch();
  return useCallback(
    (action) => {
      dispatch(untrackDeep(action));
    },
    [dispatch],
  );
};