Last year, we partnered with Samsung to create a Built-In Appliance Visualiser for the European market. In the course of developing a single-page web application in React, our team faced a number of challenges in terms of how React components are built, composed and populated with data. This article describes these problems and our approach – dubbed “data-driven React” – to solving them.
CHALLENGES
The project exhibited the following characteristics:
- Rapid and major changes to the UI as the user experience went through a lengthy, iterative design process.
- The lack of a content management system (CMS) for building pages and populating them with data (especially a CMS that allows for composing views of React components).
- The need for the client and various stakeholders to be able to modify aspects of the UI, such as theming and layout, without requiring developer involvement and a redeployment of the application as such changes are implemented.
Given these impositions, the team opted to implement view composition, and data population, of React components in a declarative – “data-driven” – manner, as described in the sections below.
IMPLEMENTATION
The foundation for addressing the aforementioned challenges is the ability to declaratively create pages (or “views”) in a format that can be parsed and rendered by React. We use JSON for this purpose, structured as follows:
{"views":[{"path":"/","components":[{"type":"ComponentA","components":[{"type":"ComponentB"}]},{"type":"ComponentC"}]}]}
Listing 1
Each object in the views array constitutes a view, which is a top-level component that is rendered as the user navigates to an URL matching the path. The view is composed of other React components, listed in the the components array. Each component is specified by its type and any child components are also listed in its components array; in listing 1, the component of type ComponentA contains a component of type ComponentB. The component hierarchy for each view is traversed recursively and the resulting component tree is constructed and stored in application state (the application uses Redux for state management).
DATA POPULATION
In React, components receive data via properties (“props”). The JSON format allows for passing data to a component as follows:
{"type":"Image","props":{"src":"/path/to/image","alt":"Some alternative text"}}
Listing 2
Using the props in the Image component:
constImage=({ src, alt })=>;constmapStateToProps=(state, ownProps)=>({...getProps(state, ownProps)});exportdefaultconnect(mapStateToProps)(Image);
Listing 3
When the component tree for a view is created, each component is assigned a unique identifier (as a prop). This ID is mapped to the component’s props object. When an Image instance is rendered (as part of a view’s component tree), it receives its props from the application state by calling a getProps selector, which uses the component’s ID – contained in ownProps – to get the corresponding props object in listing 2.
SELECTING DATA
The above example illustrated data population using “static” data; typically components receive data that’s fetched remotely at runtime. As an example, the alternative text used in the Image component is likely to be subject to localization. Therefore, we must be able to specify a certain kind of string to be used when the component is rendered, passing the appropriate value depending on the user’s current locale. We accomplish this via so-called property selectors:
{"type":"Image","props":{"src":"/path/to/image","alt":"select:content.strings.alt.welcome"}}
Listing 4
In listing 4, the alt prop has the value select:content.strings.alt.welcome. The select: prefix instructs the application to look up the value for the property selector content.strings.alt.welcome, which is an object path referencing application state:
// application state (in Redux). state ={content:{strings:{alt:{welcome:'Welcome to the application!'}}}}
Listing 5
The getProps selector shown in listing 3 recurses through a component’s props object and resolves the property selectors to their actual values, stored and retrieved from application state.
FETCHING DATA
Property selectors ensure that components are populated with data from application state, but how does the application know what data to fetch in the first place?
Data in a React application is typically fetched by dispatching actions in the component that has a dependency on said data, i.e. the determination of what data to fetch is determined at compile time. In our approach however, data dependencies may arbitrarily change as a result of editing the views and their components in the JSON declaration and thus deciding what data to load must be done upon navigating to a view as follows:
- Retrieve the parsed component tree for the view and for each component, get all its property selectors.
- Pass the resulting list of all components’ property selectors to an action resolver, which determines the actions that must be dispatched for the gathered property selectors.
- Dispatch the actions and upon their completion render the view’s component tree; each component will now be able to retrieve their props’ data from application state.
In listing 4, the property selector select:content.strings.alt.welcome is gathered and passed to the root action resolver. Action resolvers are akin to Redux state reducers – they operate on a slice of the application state to return the proper action for fetching data corresponding to that slice. As an example, for the property selector in listing 4, there could be an action resolver for the slice content.strings, returning a fetchStrings action that fetches all the application’s string data (or just the strings indicated by the property selectors, i.e. alt.welcome in this instance).
Dispatching Actions
With property selectors, an action may be dispatched multiple times, as depicted in listing 6:
{"type":"Explore","props:"{"filterOptions":"select:filters.byCategory","allProducts":"select:content.products.all"}}
Listing 6
The Explore component allows the user to view and filter all products; filter options and products are passed to the component via the property selectors select:filter.byCategory (filter options by product category) and select:content.products.all respectively.
Filter options are implicitly dependent on product data however, and as such product data will be fetched twice when the component is to be rendered. Given an action creator fetchProducts, it would typically check whether product data already exists in application state. That will not be the case when the action is dispatched for either property selector, since the server response for the first action dispatch will not reach the client before the second action is dispatched.
To ensure that an action for the same data is only dispatched once, and that actions are dispatched in the proper order, the application builds a directed acyclic graph (DAG) of all actions for a given view (based on its property selectors); the DAG for listing 6 would be as follows:
setCategory -> fetchFilters------ -> fetchProducts
The select:filters.byCategory property selector resolves to the setCategory action, which has a dependency on the fetchFilters and fetchProducts actions. The select:content.products.all property selector also resolves to the fetchProducts action. The application performs a depth-first traversal of the DAG and dispatches the actions in the following order:
(select:filters.byCategory)
- fetchFilters
- fetchProducts
- setCategory
(select:content.products.all)
- fetchProducts
The fetchProducts action will be marked as having been dispatched when the application attempts to dispatch it for the second time; thus the application simply awaits the completion of the action.
CONCLUSION
This data-driven approach to building React applications proved highly beneficial in tackling the challenges described earlier. Composing components and populating them with data is decoupled from application internals and allows for developers and non-developers alike to swiftly iterate on the user experience as requirements change, using a straightforward JSON format. By simply deploying a new version of a JSON file, the client is able to deliver desired application features to users without forcing them to download updated code bundles.
The SAMSUNG Built-In Appliance Visualiser is live, you can try it here.