A Lean Redux
I have started using .NET MAUI at work this past week. MAUI is the successor to Xamarin, but draws on much of the same tooling and paradigms. In its present state the documentation is pretty sparse, and it seems many enterprise-level issues are left for developers to resolve. That’s no issue with me, though the docs can be surprisingly unhelpful, leaving one frustrated.
One omission I noticed early on is the lack of a state management pattern. MAUI provides no tooling itself and does not recommend how to manage state between UI components.
State Management
When I’ve written React apps I have found it valuable to make use of the Redux pattern for any state management above a certain complexity. That is, I typically won’t use it to manage state in an isolated form, but as soon as that form begins to interact with other state, and particularly across components, it becomes a suitable pattern.
I do, however, think Redux has issues, particularly in the amount of boilerplate required, and the use of strings as the reference to mutations on state. Passing anything other than the action itself to mutate state leads to seemingly unnecessary code:
function reducer(state, action) { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } }
The problem with the above, taken from the React docs, is that in a sufficiently complex case, this switch statement would become unreadable. This solution does not scale, yet Redux is supposed to be a scaling solution.
Now some of this may be limitations of Javascript, which Redux is written for, but even in a strongly-typed language like C#, not passing the action itself increases boilerplate:
public static int Execute(int previousState, IAction action) { if (action is IncrementAction) { return previousState + 1; } if (action is DecrementAction) { return previousState - 1; } return previousState; }
Or, more succinctly:
public static int Execute(int previousState, IAction action) { return action switch { IncrementAction => previousState + 1, DecrementAction => previousState - 1, _ => previousState }; }
Every time a new action is required, one has to modify Execute
and
create a new implementation of IAction
. IAction
implementations
are completely empty, which I think violates the principle of least
astonishment: an implementation of an IAction
would be expected to
define the behaviour of the interface, not some isolated method called
Execute
. We also effectively cannot seal Execute
, marking it as
closed for modification, because new state mutations will always be
required.
The above example is taken from the no longer maintained Redux.NET project, and are not meant as criticisms of that project, but as a criticism of how Redux centralises state mutations, but then has you reference them indirectly. I don’t think the above issues can be resolved with a different language.
Be more abrupt
I guess I’m asking Redux to be more direct, to not have to skirt around the topic of mutating state. After all, as the caller, I’m the one making the command to do what I say, in which case It’d be better to pass what I want to have happen directly.
Perhaps I am just asking for React’s useState
, with lambdas to
setState
defined in a central place.
const actions = { increment: _ => _ + 1, decrement: _ => _ - 1 }; const [state, setState] = useState(0); setState(actions.increment);
This would work within a component, but it doesn’t solve the issue of
state managed outside of the components. We’d need to wrap setState
into a new hook to achieve that, but it is perhaps worth playing
around with.
A solution with strong typing
For MAUI I could make use of C#’s Func
and Action
types to allow
callers to pass delegates to the state manager.
sealed class State<T> { private T _state; public void Dispatch(Mutator<T> action) { _state = action(_state); } }
Dispatch
mutates the internal state by reassigning it. We can then
implement IObservable to allow observers to react to changes.
// inside State<T> private IImmutableList<IObserver<T>> _observers = ImmutableList.Create<IObserver<T>>(); public void Dispatch(Mutator<T> action) { try { _state = action(_state); _observers.ForEach(_ => _.OnNext(_state)); } catch (Exception ex) { _observers.ForEach(_ => _.OnError(ex)); throw; } }
As the underlying T
value is not accessible outside State
,
Dispatch
is the only way to mutate and ensure changes propagate to
subscribers. This is not completely encapsulated: we can’t avoid that
T
may be a reference type, but we can constrain how callers mutate
state in a way to alert subscribers. If a caller mutates the reference
type instance directly, no subscribers will know about such a
change. The change could therefore be considered invalid.
Accessing state
As the underlying value is only accessible from Dispatch
, the
question becomes how to access the state to make use of it? For this,
State
provides a selector that will return a new instance of State
.
sealed class State<T> { public State<R> Select<R>(Func<T, R> selector) => new(selector(_state)); }
But wait, that doesn’t help because the return value still
encapsulates the underlying state. This is by design, because now the
only way to access the underlying state is within the selector you
provide to Select
. This forces the caller to group their state
access into calls to Select
. Combined with Dispatch
, the intention
is for it to be unambiguous when:
- You are mutating state
- You are accessing state
Locking it down
Unfortunately, because Select
exposes the underlying value, which
may be a reference type, an unknowing developer could mutate state
within Select
, introducing bugs as modifications do not propagate to
subscribers.
There are no language features available to prevent this. Taking deep
copies of reference types in C# is non-trivial and potentially very
slow: you wouldn’t want Select
to crawl because your state is large
and heavily nested. Instead I did experiment with writing a Roslyn
analyzer to detect when modifications are being made to the underlying
value within the selector. As yet I haven’t been able to get it to
work satisfactorily, but the idea was to display an analyzer error if
assignments are detected. This would be noisy enough for most
developers to notice.
For now, I figured it was “enough” to write a doc string explaining the correct usage. This is brittle, but at least the restriction is no longer implicit.
/// <summary> /// Exposes the underlying state <typeparamref name="T"/> for access. /// </summary> /// <remarks> /// This method should <b>not</b> be used to modify the state of <typeparamref name="T"/>. /// Any modifications made here will not propagate to subscribers. /// To modify state, use <see cref="Dispatch(Mutator{T})"/>. /// </remarks> /// <typeparam name="R"></typeparam> /// <param name="selector"></param> /// <returns>A new state instance.</returns> public State<R> Select<R>(Func<T, R> selector) => new(selector(_state));