The goal here is to provide a quick and easy developer experience, using Flux mechanics. This TypeScript framework relies on developers to define application state based on interfaces, whose type information is then used to infer action ID's, action objects, action creators, and reducers. So the only boilerplate you're writing are the strongly typed definitions that comes with any React project in TypeScript.
Where Redux's implementation is based on functional programming and immutability, manifold-dx uses TypeScript to define strongly typed immutable structure, then uses generics and type inference to provide out-of-the-box API's for action creation, predefined reducers, dispatching, mapping state to components, dead simple middleware, and much more.
Since manifold-dx uses strongly typed nestable state objects, the actual shape of the application state can be whatever you want it to be, or whatever the problem requires. A deeply nested hierarchy doesn't present any problem, and component updates are performed efficiently.
As with Redux, be sure you actually need to use this. Big standalone SPA's often need libraries to manage app state, others often do not.
Let's say we have used TypeScript interfaces to define application state that looks like this:
Now suppose we want to update the user's given_name, from 'Joe' to 'Joseph' (the property on the top right).
So to make all this happen, you simply call API's that manifold-dx provides for you:
Just to reiterate, you didn't have to write anything, these API's are provided by the library:
getActionCreator
builds and invokes an action creator for youupdate
defines the action according to what you put in (code completion and type-checking courtesy of TypeScript)dispatch
updates application state and the UI
-
TypeScript Generics are a powerful feature, and we take full advantage of them, without requiring developers to know much about them. We can write type-safe, generic updates, that enforce valid property names and value types.
-
Strongly Typed Data Structures
- Developers often spend a lot of time figuring out what their application state should look like.
Given that developers have defined their state, we just add in a couple properties in the nodes
of their state graph (the blue circles in the diagrams above):
_parent
is the node that contains this one, or null if it the topmost node (application state)_myPropname
is what my parent calls me, or an empty string if the topmost node (application state)
- Example - what initial state might look like
let user: UserState = { // properties of the StateObject // parent in the state application graph _parent: userMaintenance, // parent's name for this object, ie this === this._parent[this._myPropname] _myPropname: 'user', // raw data properties given_name: '', family_name: '', email: '', UserState: '', cell: '' }
In other words, the only thing a developer has to do is to add two properties to the nodes of their application state. This makes it easy for us to generate the property path (
'userMaintenance.user.given_name"
), given only the node in the application state. - Developers often spend a lot of time figuring out what their application state should look like.
Given that developers have defined their state, we just add in a couple properties in the nodes
of their state graph (the blue circles in the diagrams above):
-
Container Class Templates Because you probably don't want to write Container classes from scratch all the time...
- You can copy and past them from the project's "templates" directory, these are your 'smart' classes that delegate to 'dumb' renderers
- TemplateContainer.tsx, TemplateRenderProps.tsx, TemplateSimple.tsx
- They contain some recommended patterns, including using FunctionComponents and React Hooks.
- Just fill in your strongly typed interfaces and mappings and write your rendering functions/components.
- You can copy and past them from the project's "templates" directory, these are your 'smart' classes that delegate to 'dumb' renderers
- See the todo app at https://github.com/mfsjr/manifold-dx-todo.
- Once you understand this, check out some more robust, scalable techniques provided in the sections below.
npm i manifold-dx
This is actually a more general question that applies to writing any UI, and it seems that the hard part is that there are a million ways to do it. I'll outline here what has worked well, along with some helper interfaces that make sure that the objects we build agree with the interfaces we have defined.
- State is comprised of:
- Nodes that are StateObjects
- Properties that are basic JS data types or plain objects
- Properties that are named (indexed by) strings
- No classes, class instances or functions
- The main observation here is that state is dynamic so everything besides the top node (application state) itself is usually optional (possibly undefined). Whether we are waiting for async results or simply writing code line-by-line state can always be optional.
- State Objects can always be obtained using optional chaining:
getStateObject(getAppStore().getState()?.uiLayout?.modal)
, where the function will throw if the state object is undefined (action creators can use optional chaining too).- You can also define accessors to return a real object, ie non-optionally, by throwing an exception if the object is undefined. So you can define accessors to grab state objects which do the checking once.
- We provide helper intefaces that enforce parent child relationships. They're easy to code and TypeScript will use them to provide code completion and flag mistakes.
Serialization application state can be de/serialized using a library called JSOG, that extends JSON serialization to handle cyclic graphs.
- How to define AppState
export interface AppData {
userMaintenance?: UserMaintenanceState;
cognito?: AppCognitoState;
}
export interface AppState extends AppData, State<null> { }
export interface UserMaintenanceState extends UserMaintenance, State<AppState> {
user?: GroupUserState;
open: boolean;
}
export interface GroupUserState extends GroupUser, State<UserMaintenanceState> { }
export interface AppCognitoState extends AppCognitoState, State<AppState> { } // phases of cognito login and person
- How to initialize AppState
export class AppStateCreator {
appState: AppState;
constructor() {
this.appState = {
_parent: null,
_myPropname: '',
};
this.appState.cognito = {
_parent: this.appState,
_myPropname: 'cognito',
groups: []
};
this.appState.userMaintenance = {
_myPropname: 'userMaintenance',
_parent: this.appState,
groups: [],
user_in_groups: [],
users: [],
};
this.appState.userMaintenance.user = {
_parent: this.appState.userMaintenance,
_myPropname: 'user',
family_name: '',
given_name: '',
email: '',
cell: '',
UserStatus: '',
open: false
};
}
}
- How to hook up AppState to manifold-dx. Note that we define mutation checking for development, so if anything other than an action modifies our state, we fail fast with a descriptive error.
export class AppStore extends Store<AppState> {
constructor(_appData: AppState, _configOptions: StateConfigOptions) {
super(_appData, _configOptions);
// process.env[`REACT_APP_STATE_MUTATION_CHECKING`] = 'true';
let strictMode: boolean = process.env.REACT_APP_STATE_MUTATION_CHECKING ?
process.env.REACT_APP_STATE_MUTATION_CHECKING === 'true' :
false;
let detection = this.getManager().getActionProcessorAPI().isMutationCheckingEnabled();
console.log(`strictMode = ${strictMode}, mutation detection=${detection}`);
}
}
let appStore = new AppStore(new AppStateCreator().appState, {});
export const getAppStore = (): AppStore => appStore;
-
How to access application state: optional chaining using the
getStateObject
method.Since application state is dynamic, it is generally declared to be optional or unioned with 'undefined'. So, optional chaining can be used to get state objects.
However, after the app is initialized, app state objects have usually been created, and verifying that they are actually defined is cumbersome. In other words, after your app is initialized, state has usually been created, and your code usually assumes it exists (and it usually does).
So manifold-dx supplies api's that expect that state objects have been created, are declared as such (non-optional, never undefined), failing fast by throwing an error.
This allows you to use optional chaining to access state objects deterministically.
// access app state using optional chaining
const message: string = getStateObject(getAppStore().getState()?.uiLayout?.modal).message; // throws if uiLayour or modal is undefined
// or use it when creating actions
getActionCreator(getAppStore().getState()?.uiLayout?.modal).set('message', 'Your updates have been saved').dispatch(); // throws if uiLayout isundefined
- How to integrate with React Router v4 and up
- You can integrate routing with state management using RedirectDx [https://github.com/mfsjr/manifold-dx-redirect-dx]
- So you can define actions to navigate to app URL's using predetermined properties, like so
getActionCreator(getAppStore().getState()?.uiLayout).set('redirectTo', SceneUrl.MY_GROUP).dispatch();
In manifold-dx, components are lightweight classes that invoke renderers (usually functions) and create mappings between application state and renderers.
- RenderPropComponent is the preferred container, where the renderer function (view) is passed in via props
- ContainerComponent passes the renderer (view) into the constructor, or overrides the render method itself
Both of these classes require the developer to write two functions:
appendToMappingActions(mappingActions: AnyMappingAction[]): void;
This is how we define the relationship between state and the renderer's properties, so that when an action changes state, the renderer's props are updated and the component re-renders.createViewProps(): VP;
is the function that initializes the view properties used by the renderer.
Here is a simple example of mapping state (a property called 'message') to a component a view property called 'alertMessage':
export class Alert extends RenderPropsComponent<AlertProps, AlertViewProps, AppState> {
constructor(_props: AlertProps) {
super(_props, getAppStore().getState());
}
protected appendToMappingActions(mappingActions: AnyMappingAction[]): void {
mappingActions.push(
getMappingActionCreator(getAppStore().getState()?.uiLayout?.modal, 'message').createPropertyMappingAction(this, 'alertMessage')
);
}
createViewProps(): AlertViewProps {
let alertMessage = getStateObject(getAppStore().getState()?.uiLayout?.modal).message || '';
return {
alertMessage,
handleClickClose: handleClickClose
};
}
}
-
You may have noticed above, where the action contains both the old and the new value. This allows actions to be 'unapplied', like a database transaction log, allowing us to do time-travel.
-
Mutation Checking, will throw errors if state is mutated by anything other than actions (careful - development only!). This is controlled by the environment variable REACT_APP_STATE_MUTATION_CHECKING.
-
Simple, Powerful Middleware - optional developer-provided functions can be invoked at various times in the lifecycle. i.e., before reducers (state changes) or after components are updated (or both).
Middleware Lifecycle:
- dispatch - is available via actions or store:
getActionCreator(stateObject).set('modalMessage', 'You cannot use the Admin UI');
store.dispatch(action1, action2, ...actionN);
- preProcessor - optionally execute code before anything changes, can read all actions, allow them to pass, or replace them
getAppStore().getManager().getActionProcessorAPI().appendPreProcessor(myPreProessor);
- reducer - You don't write reducers, manifold-dx invokes its own generic reducers that get called for you.
- actionPostReducer - optionally added to specific actions when something needs to be done immediately after a state change, e.g.
-
scoreAppendAction.actionPostReducer = () => { /* recalculate average score here */ }
- containerPostReducer - optionally added to mapping actions, invoked by the container when the state in the mapping action is updated.
Using the previous example, if we don't want to have to remember to update the average, let's put the average in the component
and use the optional containerPostReducer by appending the argument function 'this.calcAverage':
actions.push( bowlingMapper.createPropertyMappingAction(this, 'scores', this.calcAverage.bind(this)) );
- postProcessor - optionally execute code after state has updated, but immediately before component renders invoked (which are async)
- See the logging example below
- render - invoked for you by manifold-dx when app state changes, all containers mapped to the changed state will be rendered
- multiple state changes are de-duped so only there's only one render per container component (although React may re-render repeatedly)
- dispatch - is available via actions or store:
-
ActionLoggingObject interface to log actions before they change anything (or after)
let logging: string[] = []; let loggerObject: ActionLoggingObject = actionLogging(logging, false); getAppStore().getManager().getActionProcessorAPI().appendPreProcessor(loggerObject.processor);
-
Action Type Guards are provided as convenience methods, since all actions pass through Processor middleware, where you often want to find specific kinds of actions.
There are a lot of things you might want to do, like performing transforms on data that are state dependent, or like below, using
action.isStatePropChange
to validate whether the user can perform specific actions. Note that if you need to you can replace the inbound actions with whatever other actions may be needed.import { userIsAdmin } from '../auth'; // your app defines this import { createStore } from '../store'; // your app defines this const store = createStore(); const actionValidator: ActionProcessorFunctionType = // actions: Action[] => Action[] actions => { const stateObject = getStateObject(store.getState()); for(let i = 0; i < actions.length; i++) { const action = actions[i]; if (action.isStatePropChange() && action.parent === stateObject && action.propertyName === 'redirectTo' && action.value === '/admin/secret/ui' && !userIsAdmin()) { const replacementAction = getActionCreator(stateObject).set('modalMessage', 'You cannot use the Admin UI'); return [replacementAction]; } } return actions; }; store.getManager().getActionProcessorAPI().appendPreProcessor(actionValidator);
- 'set' API a convenience method that will do insert, update or remove (delete) depending on old and new data values.
- React Router (v4+) integration via RedirectDx [https://github.com/mfsjr/manifold-dx-redirect-dx]
Obviously Redux has been our frame of reference, but Vuex should be mentioned as it influenced this design in a couple of ways:
- State is modified synchronously, although in our case async aspects should be handled elsewhere (separation of concerns).
- Since state is global, we have no need for declarative/nested access, we just declare it globally, eg:
export const appStore = new AppStore(new AppStateCreator().appState, {});
Also note, a coincidental similarity with Vuex is a somewhat nested/compositional approach to state, as opposed to Redux's preferred 'flat' shape.
To Run Tests: npm test --runInBand REACT_APP_STATE_MUTATION_CHECKING=true
runInBand
since we need to have tests execute in order- and we want REACT_APP_STATE_MUTATION_CHECKING on when testing or debugging.
- this will also turn on state diff output, when mutations are detected
- remove recompose
- more README updates
- Action instance type guard methods to facilitate easier use of ProcessorAPI's (see above)
- isStatePropChange, isStateArrayChange, isMappingChange
- Improved test coverage: added unit tests
- removed unused code
- fixed (successfully executing) non-terminating unit test
- Un- or rarely-used API's:
- one minor fix,
- renamings to avoid confusion
- Enhancing usability, optional chaining
- Generic type-safe accessor
const name = getStateObject(store.getAppState()?.name);
- Keeping up to date with recent TypeScript and React releases
- Dev tools for action replay (time travel)