16
Functional React State Management with FunState and TypeScript
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.
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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.
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);
});
});
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);
};
Then in the component you can just partially apply the state:
...
<input value={value} onChange={onCountChange(state)} />
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);
});
});
- 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!
16