At the component level: useState

Keep your state at the component level whenever it is not required elsewhere. useState is a Hook that takes in an optional initial state and returns an ordered array where the first element is the state and the second is a setter function. You can name these however you’d like.

export const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={setCount(count + 1)}>{count}</button>;
};

TypeScript users can also specify the type if it cannot be inferred, such as when it starts off as undefined:

export const List = () => {
  const [selectedItem, setSelectedItem] = useState<MyItem>();

  return /* ... */;
};

In higher up components: createContext and useContext

[create|use]Context lets you track global state (e.g theme, user, locale, settings, etc) without having to keep passing it down components as props. There are a few steps:

  1. Create a context with createContext, optionally passing it a default (not initial) value
  2. Wrap the components that want to access to the current value in a context provider
  3. Consume the context with the useContext Hook
const LocaleContext = createContext("en-US");

const GreatGrandChild = () => {
  const locale = useContext(LocaleContext);

  return (
    <p>
      Locale from {`<GreatGrandChild />`}: {locale}
    </p>
  );
};
const GrandChild = () => <GreatGrandChild />;
const Child1 = () => <GrandChild />;

const Child2 = () => {
  const locale = useContext(LocaleContext);

  return (
    <p>
      Locale from {`<Child2 />`}: {locale}
    </p>
  );
};

const App = () => (
  <>
    <LocaleContext.Provider value={"en-GB"}>
      <Child1 />
    </LocaleContext.Provider>
    {/* This component will read the default value as it is not inside of a context provider */}
    <Child2 />
  </>
);

context being passed

You can optionally pass down a setter function to modify the context state from child components:

const LocaleContext = createContext<{
  locale: string;
  setLocale?: (locale: string) => void;
}>({ locale: "en-US" });

const GreatGrandChild = () => {
  const { locale, setLocale } = useContext(LocaleContext);

  return (
    <p>
      <button onClick={() => setLocale!("fr")}>🇫🇷 </button>
      Locale from {`<GreatGrandChild />`}: {locale}
    </p>
  );
};

// ...

const Child2 = () => {
  const { locale } = useContext(LocaleContext);

  return (
    <p>
      Locale from {`<Child2 />`}: {locale}
    </p>
  );
};

const App = () => {
  const [locale, setLocale] = useState("en-GB");
  return (
    <>
      <LocaleContext.Provider value={ { locale, setLocale } }>
        <Child1 />
      </LocaleContext.Provider>
      <Child2 />
    </>
  );
};

At the global level: Redux (Toolkit)

Redux is complicated enough to deserve its own article. However at a high level it does the following:

  1. A component dispatches an action
  2. A reducer listens for actions and modifies the state
  3. The component selects the portions of state it’s interested in

The benefits of this approach are decoupling, sharing state, caching state, and more maintainable state logic as your app grows. However it is not always the right approach. Below is a simplified version of the create-react-app Redux example. First we define our state. In this case, it only contains the value of our counter.

export interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
};

Then we will need to define the reducers for this state. In this case there are 2 reducers: One for incrementing the value and one for decrementing:

export const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

Finally, we export the actions (generated for us) and create a selector that will return the value given the state:

export const { increment, decrement } = counterSlice.actions;
export const selectCount = (state: RootState) => state.counter.value;

export default counterSlice.reducer;

In our component we can get the value of our counter using the useAppSelector Hook and passing it the selector we made earlier. We then dispatch our actions using the function returned from useDispatch.

export const Counter = () => {
  const count = useAppSelector(selectCount);
  const dispatch = useAppDispatch();

  return (
    <div>
      <span>{count}</span>
      <div>
        <button onClick={() => dispatch(decrement())}>-</button>
        <button onClick={() => dispatch(increment())}>+</button>
      </div>
    </div>
  );
};