This is a React + Redux project.
To simplify Redux development, setters and getters are automatically created for everything in the store, including nested properties.
Given:
const initialState = {
screen: 'Home',
seedbed: {
total: 0
}
}
To write to the store:
import {set} from '../store/Store';
const dispatch = useDispatch();
dispatch(set.screen('Seedbed'));
dispatch(set.seedbed.total(999));
To read from the store:
import {get} from '../store/Store';
const screen = useSelector(get.screen);
const total = useSelector(get.seedbed.total);
You can still access the store like this:
import {get} from '../store/Store';
const screen = useSelector(state => state.screen);
const total = useSelector(state => state.seedbed.total);
The get
methods are simply syntactic sugar, which may be slightly slower for deeply nested properties.
However, the set
methods save you the trouble of writing setter reducers for every property in the store.
Setters often need side effects. Because setters are automatically created, we need a way to hook into them.
The afterChange
object in the store accomplishes this.
For example, this sets the default rate, price, and expected fertilizer N credit based on the species selected:
const afterChange = {
// ...
species: (state, action) => {
const {index, value} = action.payload;
if (Number.isFinite(index)) {
state.rates[index] = (state.dbseedList[value] || {}).seedingRate || '';
state.prices[index] = (state.dbseedList[value] || {}).price || '';
state.focus = `rates${index}`;
const fertN = (state.dbseedList[value] || {}).NCredit || '';
if (fertN) {
state.fertN = fertN;
}
}
},
// ...
}
initialState
can contain "functional" properties.
In this example, fullName
will be calculated whenever firstName
or lastName
changes:
let initialState = {
firstName: '',
lastName: '',
fullName: (state) => state.firstName + ' ' + state.lastName,
...
}
fullName
can then be accessed like any other state property:
const fullName = useSelector(get.fullName);
A side benefit: Functional properties are calculated only when the properties they rely on change. This means that they are "auto" memoized.
The Redux store accepts a "serializable" state only, so initialState
generally can't contain method calls.
Before sending initialState
to the store, the program iterates through it (recursively for nested objects), pulling out methods and changing those properties' values to undefined.
It parses each method's definition, looking for properties that the method references. (In our example, fullName
's method references firstName
and lastName
.)
When (and only when) the referenced properties' setters are called, the method is run, and its result is stored in the "functional" property.
Note that the fullName
function doesn't have to worry about mutating the state: The reducers are sent to the store using createReducer()
, which calls Immer.
Here's a more complete example from CC-Econ:
let initialState = {
fertN: 0,
fertP: 0,
fertK: 0,
$fertN: 0
$fertP: 0,
$fertK: 0,
fertNAdded: 0,
fertPAdded: 0,
fertKAdded: 0,
$fertApplication: 0,
$fertCredit: (state) => state.fertN * state.$fertN + state.fertP * state.$fertP + state.fertK * state.$fertK,
$fertCost: (state) => -(state.fertNAdded * state.$fertN + state.fertPAdded * state.$fertP + state.fertKAdded * state.$fertK) - state.$fertApplication,
fertility: {
...
total: (state) => state.$fertCredit + state.$fertCost
},
}
If fertN
changes:
$fertCredit
's method will run and update$fertCredit
's value, because it referencesstate.fertN
.- Any methods that rely on
$fertCredit
will run and update their properties – in this case,fertility.total
. - Any methods that rely on
fertility.total
will run. Since there are none, the updates are finished. - Since none of
$fertCost's
referenced properties were affected, its method will not run.
The standard way to write this would be to create setters for each property (fertN
through $fertApplication
), making sure that each setter calls functions to update $fertCredit
, $fertCost
, and fertility.total
.
Now, the setters are created automatically, and $fertCredit
, $fertCost
, and fertility.total
are automatically calculated as needed.
Note that functional properties don't need to be simple one-liners.
For example, this will set state.coverCropTotal
whenever species
, rates
, or prices
change:
let initialState = {
// ...
coverCropTotal: (state) => {
let total = 0;
state.species
.forEach((s, n) => {
if (s) {
total += (state.rates[n] || 0) * (state.prices[n] || 0);
}
});
return total;
},
// ...
}
<Input>
components interact with the store. The type of the store's property usually determines the <Input>
type:
- If the property is a string, the type will be
text
. - If the property is a Boolean, the type will be
checkbox
. - If the property is numeric or
undefined
, the type will benumber
. - If the property begins with a dollar sign, the type will be
number
, and a dollar sign will appear before the input. (If you want a "dollar" type for a property that doesn't include$
in its name, usetype="dollar"
.)
For example, this code creates a text input for farm
, a numeric type for acres
, and a numeric type for labor
with a prepended dollar sign:
const initialState = {
// ...
farm: '',
acres: undefined,
$labor: undefined,
species: [],
// ...
}
import {Input} from './Inputs';
<Input id="farm" />
<Input id="acres" />
<Input id="$labor" />
If the store's property is an array, include an index
property:
<Input id="species" index={0} />
<Input id="species" index={1} />
<Input id="species" index={2} />
By default, changes to an <Input>
are not committed to the store unless Enter is pressed or the <Input>
component is blurred. (This is in keeping with the Redux recommendation to Avoid Putting Form State In Redux.)
If changes should be committed immediately, include immediate
as a property:
<Input id="farm" immediate />
If you want all <Input>
elements to be committed to the store immediately, include options="immediate"
on the form itself:
<form options="immediate">
An <Input>
can be focused like this:
dispatch(set.focus('farm'));
A radio group can be created like this:
<Input
id="number"
label="Pick a number"
options={[1, 2, 3]}
type="radio"
/>
If the radio buttons' labels should be different from their values, do this:
<Input
id="number"
label="Pick a number"
options={[1, 2, 3]}
labels={['One', 'Two', 'Three']}
type="radio"
/>
In JavaScript, onChange()
isn't triggered until after the input is entered or blurred. In Material UI, it's triggered after every keystroke, which is more like JavaScript's onInput()
event.
There are disadvantages to Material UI's onChange()
event, because we don't want a render on every keystroke. For this reason, the onChange()
event for <Input>
components works like JavaScript's method, but with one difference: It will return the element's value as a second parameter:
onChange={(event, newValue) => {
// ...
}}
You can still access the value using event.target.value
, but it's not necessary to do so.