25
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:
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);
});
});
mockState
.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!
25