We've now seen how to combine reducers horizontally on the same TState
type and the same Delta
class type.
<School, AddStudentAchievement>
.
We've also seen how to reuse a reducer to reduce a different TState
type (Student
), but so far we've only shown how to
create reducers that can reduce a specific Delta
type.
<School, AddStudentAchievement>
<Student, AddStudentAchievement>
This tutorial will demonstrate how we can also take reducers that work on different Delta
states and combine them into a single
reducer that can accept any type of Delta
and execute only the reducers defined to handle that Delta
state type.
We now need the ability to change the HeadStudent
property of our School
. If the student identified in
the Delta
is not already the HeadStudent
our reducer should create a new
School
state with the HeadStudent
from the Delta state.
Create a new console app with the following State
and Delta
classes
record School(ImmutableArray<Student> Students, Student HeadStudent);
record Student(int Id, string Name)
{
public ImmutableHashSet<string> Achievements { get; init; } = ImmutableHashSet.Create<string>(StringComparer.InvariantCultureIgnoreCase);
}
record AddStudentAchievement(int StudentId, string Achievement);
record ChangeHeadStudent(Student Student);
These are the same as in Tutorial 5, but with the addition of a
ChangeHeadStudent
delta which we will use to change the school's HeadStudent
.
Add the reducers from Tutorial 5 as follows:
var studentAddAchievementReducer = Reducer
.Given<Student, AddStudentAchievement>()
.When((student, delta) => student.Id == delta.StudentId && !student.Achievements.Contains(delta.Achievement))
.Then((student, delta) => student with { Achievements = student.Achievements.Add(delta.Achievement) });
var schoolStudentsAddAchievementReducer = Reducer
.Given<School, AddStudentAchievement>()
.WhenReducedBy(x => x.Students, studentAddAchievementReducer)
.Then((school, students) => school with { Students = students });
var schoolHeadStudentAddAchievementReducer = Reducer
.Given<School, AddStudentAchievement>()
.WhenReducedBy(x => x.HeadStudent, studentAddAchievementReducer)
.Then((school, headStudent) => school with { HeadStudent = headStudent });
var schoolAddStudentAchievementReducer = Reducer.Combine(schoolHeadStudentAddAchievementReducer, schoolStudentsAddAchievementReducer);
State diagram
Now add a reducer to combine the School
state ChangeHeadStudent
delta.
var schoolChangeHeadStudentReducer = Reducer
.Given<School, ChangeHeadStudent>()
.When((school, delta) => school.HeadStudent.Id != delta.Student.Id)
.Then((student, delta) => student with { HeadStudent = delta.Student });
- Given the inputs are a
School
state and aChangeHeadStudent
delta - When the school has a
HeadStudent
that has a differentId
to the one in the delta - Then we need a new
School
state based on the current state, but with theHeadStudent
state replaced.
Now we have two reducers that work on the same State
type, but expect different Delta
types. Because they expect different
Delta
types, this means consumers of our reducers won't make the mistake of calling one without the other, because you would
call one or the other depending on what you wish to change.
It would be ideal if we could combine these top-level School
reducers into a single reducer. This way, consumers would know there
is only a single reducer for School
to which they can pass any Delta
type, and won't have to dig to find which one they should
be calling.
We could even make all reducers internal to our assembly except the ones the consumer should access.
The signatures for these two School
reducers are as follows
schoolAddStudentAchievementReducer
is of typeFunc<School, AddStudentAchievement, School>
schoolChangeHeadStudentReducer
is of typeFunc<School, ChangeHeadStudent, School>
And Reducer.Combine
expects a params
array of two or more Func
of the same type, so it cannot combine them.
Using the Add
method on Reducer.Builder<TState>
it is possible to combine reducers with various Delta
types as long
as they receive and return the same TState
type. In our case this would be
- Func<School, AddStudentAchievement, School>
- Func<School, ChangeHeadStudent, School>
The convention for building with Reducer.CreateBuilder<T>
is as follows:
var schoolReducer = Reducer.CreateCompositeBuilder<School>()
.Add(schoolAddStudentAchievementReducer)
.Add(schoolChangeHeadStudentReducer)
.Build();
Combining these reducers with Reducer.CreateCompositeBuilder<TState>
returns a function with a signature of
Func<TState, object, TState
. Meaning passing objects of any type as the Delta
, and the TState
will only have an
affect if there is a reducer that reduces that Delta
state into the TState
. The Delta
state can be an object of
any form, making it a polymorphic reducer.
Use the following code to create and execute our polymorphic School
reducer.
#region Reducers from previous example
// Create a reducer that adds an achievement to a student
// with a specific Id, only if they do not already
// have that achievement.
var studentAddAchievementReducer = Reducer
.Given<Student, AddStudentAchievement>()
.When((student, delta) => student.Id == delta.StudentId && !student.Achievements.Contains(delta.Achievement))
.Then((student, delta) => student with { Achievements = student.Achievements.Add(delta.Achievement) });
// Create a reducer for the school, which uses the previous reducer
// on each student in its `ImmutableArray<Student> Students` property
// to add an achievement to a student with a specific Id if they do not
// already have that achievement.
var schoolStudentsAddAchievementReducer = Reducer
.Given<School, AddStudentAchievement>()
.WhenReducedBy(x => x.Students, studentAddAchievementReducer)
.Then((school, students) => school with { Students = students });
// Create a reducer for the school, which uses the student reducer
// on its `Student HeadStudent` property
// to add an achievement if they have the correct Id and if they do not
// already have that achievement.
var schoolHeadStudentAddAchievementReducer = Reducer
.Given<School, AddStudentAchievement>()
.WhenReducedBy(x => x.HeadStudent, studentAddAchievementReducer)
.Then((school, headStudent) => school with { HeadStudent = headStudent });
// Create a combined reducer that takes School as a state,
// AddStudentAchievementAction as an action
// and returns new school state if any of the Students or the HeadStudent
// states were modified.
var schoolAddStudentAchievementReducer = Reducer.Combine(schoolHeadStudentAddAchievementReducer, schoolStudentsAddAchievementReducer);
#endregion
// Create a reducer that replaces the head student
// of the school, because Steven Cramer is too smelly.
var schoolChangeHeadStudentReducer = Reducer
.Given<School, ChangeHeadStudent>()
.When((school, delta) => school.HeadStudent.Id != delta.Student.Id)
.Then((student, delta) => student with { HeadStudent = delta.Student });
var student1 = new Student(1, "Peter Morris");
var student2 = new Student(2, "Steven Cramer");
var allStudents = ImmutableArray.Create<Student>(student1, student2);
var school = new School(allStudents, HeadStudent: student2);
var addAchievementDelta = new AddStudentAchievement(2, "Smells");
var changeHeadStudentDelta = new ChangeHeadStudent(student1);
// Now build a reducer that can handle both
// `Delta` types by allowing us to pass `TState` + `object`
var schoolReducer = Reducer.CreateCompositeBuilder<School>()
.Add(schoolAddStudentAchievementReducer)
.Add(schoolChangeHeadStudentReducer)
.Build();
(bool changed, school) = schoolReducer(school, addAchievementDelta);
(changed, school) = schoolReducer(school, changeHeadStudentDelta);
(changed, school) = schoolReducer(school, "We don't have a reducer for a simple string delta, so this does nothing");
The state in this example changes as follows, note how a single reducer is used to reduce any Delta
state, and how
trying to reduce with an unknown Delta
state has no effect. Not only does this make it possible to dispatch all Delta
states to a single reducer for School, but also gives us the ability to create a single root
reducer for our whole state,
so the consumers of our reducers never need to worry about which reducer they should use.
Step=0, Changed=False,
State={
"Students": [
{
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
},
{
"Id": 2,
"Name": "Steven Cramer",
"Achievements": []
}
],
"HeadStudent": {
"Id": 2,
"Name": "Steven Cramer",
"Achievements": []
}
}
Step=1, Changed=True,
State={
"Students": [
{
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
},
{
"Id": 2,
"Name": "Steven Cramer",
"Achievements": [
"Smells"
]
}
],
"HeadStudent": {
"Id": 2,
"Name": "Steven Cramer",
"Achievements": [
"Smells"
]
}
}
Step=2, Changed=True,
State={
"Students": [
{
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
},
{
"Id": 2,
"Name": "Steven Cramer",
"Achievements": [
"Smells"
]
}
],
"HeadStudent": {
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
}
}
Step=3, Changed=False,
State={
"Students": [
{
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
},
{
"Id": 2,
"Name": "Steven Cramer",
"Achievements": [
"Smells"
]
}
],
"HeadStudent": {
"Id": 1,
"Name": "Peter Morris",
"Achievements": []
}
}
- It is possible to combine reducers with different
Delta
class types by usingReducer.CreateCompositeBuilder<TState>
. - You can pass as many existing reducers as you wish, but they must all have the same
TState
type. - The signature of the reducer returned will be
Func<TState, object, TState>
. - It is possible to reduce a
Delta
of any class type into this reducer, any type without a reducer will have no effect.
In only a few lines of code (most if it is comments) we were able to create the following state system.