-
Notifications
You must be signed in to change notification settings - Fork 2
Using React with URSYS
URSYS WebApps try to keep data- and app-level operations code out of the user interface, so our code can be run without an GUI or be easily adapted to other GUI frameworks. Here's an example of how a React root component interfaces with URSYS lifecycle and appcore modules.
Note
Many URSYS WebApps were written before React Functional Components and Hooks; we don't have URSYS hooks best practices available at the time of this writing in May 2024.
Much boilerplate stuff removed for clarity.
// DevWizard.jsx
/** IMPORT LIBRARIES **/
import React from 'react';
import UR from '@gemstep/ursys/client';
import * as WIZCORE from 'modules/appcore/ac-wizcore';
import * as DC_PROJECTS from 'modules/datacore/dc-projects';
import { ButtonConsole } from './wiz/edit/ButtonConsole';
/** lifecycle hook **/
UR.HookPhase('UR/LOAD_ASSETS', () => {
return DC_PROJECTS(`/assets/projectname`);
});
/** react root **/
class DevWizard extends React.Component {
constructor() {
this.state = WIZCORE.State();
}
componentDidMount() {
UR.SystemAppConfig({ autoRun: true }); // initialize renderer
document.addEventListener('click', WIZCORE.DispatchClick);
WIZCORE.SubscribeState(this.handleWizUpdate);
}
componentWillUnmount() {
WIZCORE.UnsubscribeState(this.handleWizUpdate);
}
/** handle incoming WIZCORE state updates **/
handleWizUpdate(vmStateEvent) {
this.setState(vmStateEvent);
}
/** react render function **/
render() {
const { sel_linenum, sel_linepos, script_page } = this.state;
return (
<div id="gui-wizard">
<ScriptViewWiz_Block script_page={script_page} sel_linenum={sel_linenum} />
<ButtonConsole />
</div>
);
}
}
/** component export **/
export default DevWizard;
URSYS controls its lifecycle from application load to start with Phase Hooks. One of the most used ones in any module is to tap into a LOAD_ASSETS
that happens very early in system initialization, even before React renders anything.
/** lifecycle hook **/
UR.HookPhase('UR/LOAD_ASSETS', () => {
return DC_PROJECTS(`/assets/projectname`);
});
Here, it's using the UR
phasemachine, which in GEMSTEP controls the app's overall lifecycle. As a quick synopsis, the lifecycle stages for the UR
PhaseMachine are something like this:
'SYS_INIT', // initialize key runtime parameters
'DOM_READY', // the dom is stable
'NET_CONNECT', // initiate connection to URNET message broker
'NET_READY', // the network is stable with initial services
'LOAD_DB', // load database stuff from wherever
'LOAD_CONFIG', // app modules can request asynchronous loads
'LOAD_ASSETS' // can use loaded configs to load assets
'APP_CONFIGURE' // app modules can configure data structure from loaded data
'APP_READY' // all apps have loaded and configured and are ready to run
'APP_START', // app modules start execution, all modules are ready
React components construct only after React is invoked. In GEMSTEP, this happens after LOAD_ASSETS
fires. Note that because the PhaseMachine hook UR/LOAD_ASSETS
is defined at the time of module load, it's able to fire before React initializes. This order-of-operations is handled by application bootstrap code that controls the application lifecycle.
In React class components, the constructor is used for first initialization of state and data structures. Instead of using code like this.state = { stuff }
, we're instead fetching the initial state from the WIZCORE
appcore module.
constructor() {
this.state = WIZCORE.State();
}
The WIZCORE
appcore is using AppStateMgr to initialize state with this code:
// ac-wizcore.ts
/** import class **/
import UR from '@gemstep/ursys/client';
const { StateMgr } = UR.class;
/** create StateMgr instance **/
const STORE = new StateMgr('ScriptWizard');
const { State, SendState, SubscribeState } = STORE;
/** initialize StateMgr for use with React component constructors **/
STORE._initializeState({
script_page: [], // source tokens 'printed' as lines
sel_linenum: -1, // selected line of wizard. If < 0 it is not set, pointer to script_page
sel_linepos: -1, // select index into line. If < 0 it is not set
});
...
/** export StateMgr's needed messages **/
export { State, SendState, SubscribeState };
The State()
method returns the state object (which is React-compatible).
Because we're using WIZCORE's StateMgr class instance to manage our state, we have to create a bidirectional link.
/** appcore-to-reactstate integration **/
componentDidMount() {
UR.SystemAppConfig({ autoRun: true }); // initialize renderer
WIZCORE.SubscribeState(this.handleWizUpdate);
}
React's componentDidMount()
will fire after this component has been fully rendered, and since it's the root view that means that all its subviews have rendered as well. There are a couple things shown here:
- A call to
UR.SystemAppConfig()
tells the lifecycle system to start running the other phases, which areAPP_CONFIGURE'
,'APP_READY'
, and'APP_START'
. That means as soon as the application has completely initialized, we can make assumptions about what is available in the system. - The
WIZCORE.SubscribeState(this.handleWizUpdate)
call receives notification from WIZCORE's state manager instance, which fires whenever that state updates. Since multiple components can import WIZCORE, that means a change made by another component will be propagated to all subscribers.
In this example, handleWizUpdate(vmStateEvent)
just uses React's this.setState()
directly without any filtering or processing.
handleWizUpdate(vmStateEvent) {
this.setState(vmStateEvent);
}
This will of course cause the render()
function to be invoked by React, passing props down to the <ScriptView>
child component so it knows what to render.
/** react render function **/
render() {
const { sel_linenum, sel_linepos, script_page } = this.state;
return (
<div id="gui-wizard">
<ScriptView script_page={script_page} sel_linenum={sel_linenum} />
<ButtonConsole />
</div>
);
}
So that's how state is initialized in the appcore and read by the component. Now let's talk about the reverse direction.
React is responsible for capturing user-interface events and also updating its display state, and loops through WIZCORE's export SendState()
method instead of using this.setState()
in the component. This eventually fires the handleWizUpdate()
method of the component which then calls this.setState()
.
Here's a slight modification of the sample code to include a textarea and a submit button with event handlers:
/** local UI handlers **/
updateText(event) {
const script_text = event.target.value;
WIZCORE.SendState({ script_text });
// render() function will get updated via this.handleWizEvent()
}
submitText(event) {
const { script_text } = this.state;
WIZCORE.SaveScriptText(script_text);
}
/** render function with events **/
render() {
const { sel_linenum, sel_linepos, script_page } = this.state;
const { script_text } = this.state;
return (
<div id="gui-wizard">
<ScriptViewWiz_Block script_page={script_page} sel_linenum={sel_linenum} />
<ButtonConsole />
<textarea rows={20} value={script_text} onChange=event=>this.updateText(event) />
<button type="submit" onClick=event=>this.submitText(event)>Submit Text</button>
</div>
);
}
We've added the onChange and onClick event handlers, which are bound to arrow functions that accept the event
object and pass it on to local handlers. These are doing different things, as evidenced by the way WIZCORE is being used.
- The responsibility of the textarea
onChange
handler is only to updating its appearance as the user types. It does not change application data. This handler is fired when a change occurs, and we yoink the text fromevent.target
. Using React's terminology, this is a controlled component as opposed to an uncontrolled one. - For the
onSubmit
handler of the button, we want to change application data only, so we grab the text value not out of the event.target (which is the button, not the text area), but fromthis.state
. It's up-to-date from the prioronChange
handling. we submit it to a WIZCORESaveScriptText()
method that performs the desired operation. Because WIZCORE knows what dependent state might be generated, the logic can assemble a larger state update to notify all state subscribers (e.g. a word count).
A component can include multiple AppCore modules, each with their own managed instance of StateMgr. However, you can also use the StateMgr's static method GetStateManager(groupName: string): StateMgr
to request the instance that any given AppCore may be using so you can observe state changes in a read-only manner. This helps eliminate excessive props passing in React component trees.