3.3 Frontend
Component Index
Below is a list of all the components in the app. All "dumb" components are in the views/ui
folder and the smart are contained in the views/app
and views/parser
directories.
- views/
Router
The router is the entry part of the application. The router dictates what components to mount and thus what part of the app to initialise. The router itself is a React component and works much like any other component. It has Route
components which describe the URLs and support nesting as you'd expect.
<Route component={App} onEnter={authorize}>
<IndexRoute component={Dashboard} />
<Route path="logout" component={Logout} onEnter={Logout.onEnter} />
<Route path="courses/pick" component={CoursePicker} />
<Route path="course/:course" component={Course}>
<Route path="paper/:year/:period/q/:path(/:view)" component={Question} />
<Route path="paper/:year/:period" component={Paper} />
</Route>
</Route>
In the above example, all matching components will be rendered as children to the App
component (i.e. passed as children
prop). If no route is matched, the IndexRoute
will match and the Dashboard
component will be rendered.
Direct linking
An extremely important part of the application experience is being able to direct link to content and have it load automatically without user interaction. This is trivial to implement (i.e. how they operate) with server-side rendering but a very hard problem in client side apps. The client must decide what to load for the user and how all in a split second when the page loads.
React router is able to match a deep linked URL and mount the correct components who once mounted, continue loading the application from the API. It is for this reason that every component that matches a route must be a smart component. Smart components can tell the state what data to load and select it from the state.
Back Button
Another seemingly simple function that is difficult problem in web applications is navigating back throughout the app's history. With the combination of the state defined UI and React router, the ability to navigate forward and back through history is just a consequence of great design.
Smart Components
The concept of smart and dumb components was discussed in the UI section of the
Design chapter. In this section, we'll go through some of the concepts in more detail. Smart components are connected via the react-redux
bindings. The bindings take two arguments, the state selector and a map of actions that the component will dispatch to mutate the state.
connect(mapStateToProps, mapDispatchToActions)(Component)
State selector
Every smart component is connected to the state via selectors. Selectors are functions that, given the state object, return the required data to render the view based on the current state. Take for example, the Course component. It selects the course to render from the resource.courses
state in it's selector. As a convention, the selector is stored as a static property on the component's class however it is passed as the mapStateToProps
parameter in the connect
react-redux bindings. The selector receives two parameters, the state and the props passed to the component.
class Course extends Component {
static selector = (state, { params }) => {
return {
course: state.resources.courses.find(course => {
return course.code === params.course;
});
};
};
}
There params
object comes from React-Router and contains the parameters in the url (/course/:course
). The returned object is then mapped to the component's props allowing state data to be accessed via this.props
. This selector is run every time the state changes and since the components props changes, it causes a cascading re-render. This is why it's important to have a couple of smart components.
State mutation
To mutate or update the state, we use actions. Again, as a convention, the actions map is stored as a static property on the component's class but is passed as the second parameter mapDispatchToActions
to the connect
react-redux bindings. The actions map contains action creators which are essentially just functions that return simple objects that describe how to update the state. These actions are passed to the reducers. The react-redux binds the dispatch
function to the action creators and passes them as props to the component by their key in the map.
class Course extends Component {
static actions = {
selectPaper: model.Paper.selectPaper,
comment: model.User.comment
};
}
The connect
binding
When the selector and actions map are created, it's just a matter of passing them to the connect
binding from the react-redux
. We then export the returned, wrapped component from the binding.
export default connect(Course.selector, Course.actions)(Course);
Saving selections with React's experimental context
The selectors for connected components became repetitive the further down the router tree you went because each smart component needed to access the same data. This is where the potential was spotted for React's context
.
React has an experimental feature call context
where you can pass data implicitly down the component tree and access it via a property called contextTypes
. The contextTypes
map specifies the keys to extract from the context and put into the components this.context
property.
After modifying the react-redux
bindings to introduce the context features, selectors could be thinned down and state already selected further up the component tree could be put into the context. It saved a lot of time and keystrokes without introducing unnecessary complexity to the application. See the selectors for the Paper
and Question
smart components for usage in context (no pun intended).
Tests still have to be created for the react-redux
fork that includes context but I hope to create a pull request to include the code to the popular repository soon.
API Communication
To communicate with the API, the new fetch
API was utilised (with a polyfill for old browsers and Node). The API
class is an abstraction over the fetch
API and contains all the methods to communicate with the API. The class is based on a simple idea that works extremely effective:
- Instance methods are authenticated request methods.
- Static methods are unauthenticated request methods.
You can therefore create a new API
object via new API(<session key>)
and all subsequent requests to the API made via the instance will be authenticated. There are also other methods for creating authenticated instances:
API.login(<username>, <password>).then( api )
This method logs the user in via their credentials and if successful, returns a newly authenticated API instance with the users session key.
API.loginWithAuth( <session key> ).then( api )
Validate an auth key on the
/auth
endpoint and return an new authenticated instance if successful.
The authenticated API instance is stored in the state and can be accessed whenever authorised data needs to be loaded from the API. See the src/API.js
for a full list of methods of communication with the API.
Auto-login
A tricky part of the application logic is auto logging in a user who has a valid API key. Before we can auto-login a user, we have to check if their key saved locally is valid. This sounds a lot easier that it is because this is an asynchronous action.
This would have been made simple however a design flaw in the React-router makes the implementation more complicated. By design, React Router awaits async transition handlers to complete before rendering anything to the screen or even matching routes further down the routing tree. So if the app were to check if a user's key is valid on the onEnter
hook of a router, a white screen would be left hanging since React-router awaits the completion of the async task before rendering anything or even matching further down the routing tree.
To account for this, the Root.jsx
component goes against the principles of the entire rest of the application. It uses it's own state. It becomes the root component that when mounted, displays a loading screen and checks if the user's key is valid via the API.loginWithAuth
API method. If the key is valid, it adds the user to the state and then returns the entire rest of the app as a replacement for the loading screen. The app now has the user already in the state and can continue to function normally. Had the user's key not been valid, the user would simple not exist and the app can perform a synchronous check and redirect to the login page.