DEV Community

loading...

Functional React State Management with FunState and TypeScript

Jethro Larson
Updated on ・4 min read

React 16.8 gave us hooks, a concise way of organizing our components and separating complexity from our functional components. With hooks like useState we could consider eschewing state management solutions entirely. However, trying to useState on medium to large applications has quite a few challenges:

  • Using many useState calls bloat components and causes an explosion of variables to manage as each call creates value and setter functions. This in-turn bloats child components as you have to add properties for all the related values and setters.
  • Code with useState can be difficult to write unit tests for.
  • It can be hard to refactor logic out of complex components(essentially requires custom hooks which are themselves hard to unit test.)
  • No convenient way of dealing with immutable nested data (other than the JS spread operator)
  • useReducer adds its own complexity and while simpler than redux, it introduces actions and reducers which then have to be managed in their own ways.
  • Making useState enclose a complex state object can solve some of the problems but makes it harder to write child components that are only operating on a subset of the bigger state nodes.

Another State-Management Library Appears!

FunState is a new state-management solution that leverages the convenience of useState with an api that allows for fractal, testable, and composable components.

Refactoring to Fun

Let's start with a small component using vanilla React to show how you'd convert to using fun-state:

export const Counter: FC<{
  value: number,
  onChange: (x: number) => unknown
} = (props) => {
  const onCountChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    const val = parseInt(e.currentTarget.value, 10);
    if (isFinite(val)) {
      props.onChange(val);
    }
  };
  const onUp = () => props.onChange(inc);
  const onDown = () => props.onChange(dec);
  return (
    <div>
      <input value={value} onChange={onCountChange} />
      <button onClick={onUp}>up</button>
      <button onClick={onDown}>down</button>
    </div>
  );
};

// Usage in an App
const App: FC = () => {
  const [counterValue, setCounterValue] = useState(0);
  return (
    <div>
      <Counter
        value={counterValue}
        onChange={setCounterValue} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here we can swap out useState for useFunState

import {FC, useState} from 'react';
import useFunState from '@fun-land/use-fun-state';
import {FunState} from '@fun-land/fun-state';

export const Counter: FC<{state: FunState<number>>}> = ({state}) => {
  const value = state.get();
  const onCountChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    const val = parseInt(e.currentTarget.value, 10);
    if (isFinite(val)) state.set(val);
  };
  const onUp = () => state.mod(inc);
  const onDown = () => state.mod(dec);
  return (
    <div>
      <input value={value} onChange={onCountChange} />
      <button onClick={onUp}>up</button>
      <button onClick={onDown}>down</button>
    </div>
  );
};

const App: FC = () => {
  const counterState = useFunState(0);
  return (
    <div>
      <Counter
        state={counterState} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

You may reasonably be thinking, "How is this better?" Let's explore how this code changes over time.

What if we want to have an array of counters?

Thankfully we don't have to change the implementation of Counter in either approach.

Vanilla:

const App: FC = () => {
  const [counters, setCounter] = useState([0, 1, 2, 3, 4]);
  return (
    <div>
      {counters.map((counter, i) => (
        <Counter
          value={counter}
          onChange={(val) => setCounter( counters.map((c, j) => i === j ? val : c))} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

FunState

import {index} from '@fun-land/accessor';

const App: FC = () => {
  const countersState = useFunState([0, 1, 2, 3, 4]);
  return (
    <div>
      {countersState.get().map((_, i) => (
        <Counter state={countersState.focus(index(i))} />
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The magic here is that since Counter expects a FunState<number> instance, we just need to focus on one. index is an Accessor that can point to a specific item in an array, so no custom state handling required. We're just connecting wires.

Unit Testing

One of the useful properties of components using FunState is that since the state is first-class it can be passed in. FunState also provides a library-agnostic FunState constructor, mockState, to ease unit testing.

import {render, fireEvent} from '@testing-library/react';
import {mockState} from '@fun-land/fun-state'

describe('Counter', () => {
  it('increments state when up button pressed', () => {
    const counterState = mockState(0);
    const comp = render(<Counter state={counterState} />);
    fireEvent.click(comp.getByText('up'));
    expect(counterState.get()).toBe(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

No magic mocks or spies required!

Another neat trick is to extract functions from the body of your components to keep cyclomatic complexity under control.

For example let's extract onCountChange:


const onCountChange = (state: FunState<number>): ChangeEventHandler<HTMLInputElement> = (e) => {
    const val = parseInt(e.currentTarget.value, 10);
    if (isFinite(val)) state.set(val);
  };
Enter fullscreen mode Exit fullscreen mode

Then in the component you can just partially apply the state:

...
<input value={value} onChange={onCountChange(state)} />
Enter fullscreen mode Exit fullscreen mode

Then you can test the handler directly if you like:

describe('onCountChange', () => {
  it('updates the state if a valid integer is passed', () => {
    const counterState = mockState(0);
    onCountChange(counterState)({currentTarget: {value: 12}} as ChangeEvent)
    expect(counterState.get()).toEqual(12);
  });
});
Enter fullscreen mode Exit fullscreen mode

What's great about FunState

  • Rather than adding indirection of actions and reducers just set the state in event handlers without shame
  • Focus into the state and pass subsets of it to functions or child components.
  • Write unit tests easily with provided mockState.
  • Good type-safety with typescript so the compiler can ensure that everything is copacetic
  • First-class state makes refactoring easier.
  • Integrate into existing React 16.8+ application without having to change anything else.
  • Also works with React Native
  • Tree-shakable so you only bundle what you use.

This is just the tip of the iceberg and I plan to go more in-depth on future articles. Give a ❤️ if you'd like to see more!

Discussion (0)