Putting stores into action
In this section, we're going to implement some stores in our skeleton architecture. They won't be complete stores capable of supporting end-to-end work-flows. However, we'll be able to see where the stores fit within the context of our application.
We'll start with the most basic of all store actions, which are populating them with some data; this is usually done by fetching it via some API. Then, we'll discuss changing the state of remote API data. Finally, we'll look at actions that change the state of a store locally, without the use of an API.
Fetching API data
Regardless of whether or not there's an API with application data ready to consume, we know that eventually this is how we'll populate our store data. So it makes sense that we think about this as the first design activity of implementing skeleton stores.
Let's create a basic store for the homepage of our application. The obvious information that the user is going to want to see here is the currently logged-in user, a navigation menu, and perhaps a summarized list of recent events that are relevant to the user. This means that fetching this data is one of the first things our application will have to do. Here's our first implementation of the store:
// Import the dispatcher, so that the store can // listen to dispatch events. import dispatcher from '../dispatcher'; // Our "Home" store. class HomeStore { constructor() { // Sets a default state for the store. This is // never a bad idea, in case other stores want to // iterate over array values - this will break if // they're undefined. this.state = { user: '', events: [], navigation: [] }; // When a "HOME_LOAD" event is dispatched, we // can assign "payload" to "state". dispatcher.register((e) => { switch (e.type) { case 'HOME_LOAD': Object.assign(this.state, e.payload); break; } }); } } export default new HomeStore();
This is fairly easy to follow, so lets point out the important pieces. First, we need to import the dispatcher so that we can register our store. When the store is created, the default state is stored in the state
property. When the HOME_LOAD
action is dispatched, we change the state of the store. Lastly, we export the store instance as the default module member.
As the action name implies, HOME_LOAD
is dispatched when data for the store has loaded. Presumably, we're going to pull this data for the home store from some API endpoints. Let's go ahead and put this store to use in our main.js
module—our application entry point:
// Imports the "dispatcher", and the "homeStore". import dispatcher from './dispatcher'; import homeStore from './stores/home'; // Logs the default state of the store, before // any actions are triggered against it. console.log(`user: "${homeStore.state.user}"`); // → user: "" console.log('events:', homeStore.state.events); // → events: [] console.log('navigation:', homeStore.state.navigation); // → navigation: [] // Dispatches a "HOME_LOAD" event, when populates the // "homeStore" with data in the "payload" of the event. dispatcher.dispatch({ type: 'HOME_LOAD', payload: { user: 'Flux', events: [ 'Completed chapter 1', 'Completed chapter 2' ], navigation: [ 'Home', 'Settings', 'Logout' ] } }); // Logs the new state of "homeStore", after it's // been populated with data. console.log(`user: "${homeStore.state.user}"`); // → user: "Flux" console.log('events:', homeStore.state.events); // → events: ["Completed chapter 1", "Completed chapter 2"] console.log('navigation:', homeStore.state.navigation); // → navigation: ["Home", "Settings", "Logout"]
This is some fairly straightforward usage of our home store. We're logging the default state of the store, dispatching the HOME_LOAD
action with some new payload data, and logging the state again to make sure that the state of the store did in fact change. So the question is, what does this code have to do with the API?
This is a good starting point for our skeleton architecture because there's a number of things to think about before we even get to implementing API calls. We haven't even started implementing actions yet, because if we did, they'd just be another distraction. And besides, actions and real API calls are easy to implement once we flesh out our stores.
The first question that comes to mind about the main.js
module is the location of the dispatch()
call to HOME_LOAD
. Here, we're bootstrapping data into the store. Is this the right place to do this? When the main.js
module runs will we always require that this store be populated? Is this the place where we'll want to bootstrap data into all of our stores? We don't need immediate answers to these questions, because that would likely result in us dwelling on one aspect of the architecture for far too long, and there are many other issues to think about.
For example, does the coupling of our store make sense? The home store we just implemented has a navigation
array. These are just simple strings right now, but they'll likely turn into objects. The bigger issue is that the navigation data might not even belong in this store—several other stores are probably going to require navigation state data too. Another example is the way we're setting the new state of the store using the dispatch payload. Using Object.assign()
is advantageous, because we can dispatch the HOME_LOAD
event with a payload with only one state property and everything will continue to function the same. Implementing this store took us very little time at all, but we've asked some very important questions and learned a powerful technique for assigning new store state.
This is the skeleton architecture, and so we're not concerned with the mechanics of actually fetching the API data. We're more concerned about the actions that get dispatched as a result of API data arriving in the browser; in this case, it's HOME_LOAD
. It's the mechanics of information flowing through stores that matters in the context of a skeleton Flux architecture. And on that note, let's expand the capabilities of our store slightly:
// We need the "dispatcher" to register our store, // and the "EventEmitter" class so that our store // can emit "change" events when the state of the // store changes. import dispatcher from '../dispatcher'; import { EventEmitter } from 'events'; // Our "Home" store which is an "EventEmitter" class HomeStore extends EventEmitter { constructor() { // We always need to call this when extending a class. super(); // Sets a default state for the store. This is // never a bad idea, in case other stores want to // iterate over array values - this will break if // they're undefined. this.state = { user: '', events: [], navigation: [] }; // When a "HOME_LOAD" event is dispatched, we // can assign "payload" to "state", then we can // emit a "change" event. dispatcher.register((e) => { switch (e.type) { case 'HOME_LOAD': Object.assign(this.state, e.payload); this.emit('change', this.state); break; } }); } } export default new HomeStore();
The store still does everything it did before, only now the store class inherits from EventEmitter
, and when the HOME_LOAD
action is dispatched, it emits a change
event using the store state as the event data. This gets us one step closer to having a full work-flow, as views can now listen to the change
event to get the new state of the store. Let's update our main module code to see how this is done:
// Imports the "dispatcher", and the "homeStore". import dispatcher from './dispatcher'; import homeStore from './stores/home'; // Logs the default state of the store, before // any actions are triggered against it. console.log(`user: "${homeStore.state.user}"`); // → user: "" console.log('events:', homeStore.state.events); // → events: [] console.log('navigation:', homeStore.state.navigation); // → navigation: [] // The "change" event is emitted whenever the state of The // store changes. homeStore.on('change', (state) => { console.log(`user: "${state.user}"`); // → user: "Flux" console.log('events:', state.events); // → events: ["Completed chapter 1", "Completed chapter 2"] console.log('navigation:', state.navigation); // → navigation: ["Home", "Settings", "Logout"] }); // Dispatches a "HOME_LOAD" event, when populates the // "homeStore" with data in the "payload" of the event. dispatcher.dispatch({ type: 'HOME_LOAD', payload: { user: 'Flux', events: [ 'Completed chapter 1', 'Completed chapter 2' ], navigation: [ 'Home', 'Settings', 'Logout' ] } });
This enhancement to the store in our skeleton architecture brings about yet more questions, namely, about setting up event listeners on our stores. As you can see, we have to make sure that the handler is actually listening to the store before any actions are dispatched. All of these concerns we need to address, and we've only just begun to design our architecture. Let's move on to changing the state of backend resources.
Changing API resource state
After we've set the initial store state by asking the API for some data, we'll likely end up needing to change the state of that backend resource. This happens in response to user activity. In fact, the common pattern looks like the following diagram:
Let's think about this pattern in the context of a Flux store. We've already seen how to load data into a store. In the skeleton architecture we're building, we're not actually making these API calls, even if they exist—we're focused solely on the information that's produced by the frontend right now. When we dispatch an action that changes the state of a store, we'll probably need to update the state of this store in response to successful completion of the API call. The real question is, what does this entail exactly?
For example, does the call we make to change the state of the backend resource actually respond with the updated resource, or does it respond with a mere success indication? These types of API patterns have a dramatic impact on the design of our stores because it means the difference between having to always make a secondary call or having the data in the response.
Let's look at some code now. First, we have a user store as follows:
import dispatcher from '../dispatcher'; import { EventEmitter } from 'events'; // Our "User" store which is an "EventEmitter" class UserStore extends EventEmitter { constructor() { super(); this.state = { first: '', last: '' }; dispatcher.register((e) => { switch (e.type) { // When the "USER_LOAD" action is dispatched, we // can assign the payload to this store's state. case 'USER_LOAD': Object.assign(this.state, e.payload); this.emit('change', this.state); break; // When the "USER_REMOVE" action is dispatched, // we need to check if this is the user that was // removed. If so, then reset the state. case 'USER_REMOVE': if (this.state.id === e.payload) { Object.assign(this.state, { id: null, first: '', last: '' }); this.emit('change', this.state); } break; } }); } } export default new UserStore();
We'll assume that this singular user store is for a page in our application where only a single user is displayed. Now, let's implement a store that's useful for tracking the state of several users:
import dispatcher from '../dispatcher'; import { EventEmitter } from 'events'; // Our "UserList" store which is an "EventEmitter" class UserListStore extends EventEmitter { constructor() { super(); // There's no users in this list by default. this.state = [] dispatcher.register((e) => { switch (e.type) { // The "USER_ADD" action adds the "payload" to // the array state. case 'USER_ADD': this.state.push(e.payload); this.emit('change', this.state); break; // The "USER_REMOVE" action has a user id as // the "payload" - this is used to locate the // user in the array and remove it. case 'USER_REMOVE': let user = this.state.find( x => x.id === e.payload); if (user) { this.state.splice(this.state.indexOf(user), 1); this.emit('change', this.state); } break; } }); } } export default new UserListStore();
Let's now create the main.js
module that will work with these stores. In particular, we want to see how interacting with the API to change the state of a backend resource will influence the design of our stores:
import dispatcher from './dispatcher'; import userStore from './stores/user'; import userListStore from './stores/user-list'; // Intended to simulate a back-end API that changes // state of something. In this case, it's creating // a new resource. The returned promise will resolve // with the new resource data. function createUser() { return new Promise((resolve, reject) => { setTimeout(() => { resolve({ id: 1, first: 'New', last: 'User' }); }, 500); }); } // Show the user when the "userStore" changes. userStore.on('change', (state) => { console.log('changed', `"${state.first} ${state.last}"`); }); // Show how many users there are when the "userListStore" // changes. userListStore.on('change', (state) => { console.log('users', state.length); }); // Creates the back-end resource, then dispatches actions // once the promise has resolved. createUser().then((user) => { // The user has loaded, the "payload" is the resolved data. dispatcher.dispatch({ type: 'USER_LOAD', payload: user }); // Adds a user to the "userListStore", using the resolved // data. dispatcher.dispatch({ type: 'USER_ADD', payload: user }); // We can also remove the user. This impacts both stores. dispatcher.dispatch({ type: 'USER_REMOVE', payload: 1 }); });
Here, we can see that the createUser()
function serves as a proxy for the actual API implementation. Remember, this is a skeleton architecture where the chief concern is the information constructed by our stores. Implementing a function that returns a promise is perfectly acceptable here because this is very easy to change later on once we start talking to the real API.
We're on the lookout for interesting aspects of our stores—their state, how that state changes, and the dependencies between our stores. In this case, when we create the new user, the API returns the new object. Then, this is dispatched as a USER_LOAD
action. Our userStore
is now populated. We're also dispatching a USER_ADD
action so that the new user data can be added to this list. Presumably, these two stores service different parts of our application, and yet the same API call that changes the state of something in the backend is relevant.
What can we learn about our architecture from all of this? For starters, we can see that the promise callback is going to have to dispatch multiple actions for multiple stores. This means that we can probably expect more of the same with similar API calls that create resources. What about calls that modify users, would the code look similar?
Something that we're missing here is an action to update the state of a user object within the array of users in userListStore
. Alternatively, we could have this store also handle the USER_LOAD
action. Any approach is fine, it's the exercise of building the skeleton architecture that's supposed to help us find the approach that best fits our application. For example, we're dispatching a single USER_REMOVE
action here too, and this is handled easily by both our stores. Maybe this is the approach we're looking for?
Local actions
We'll close the section on store actions with a look at local actions. These are actions that have nothing to do with the API. Local actions are generally in response to user interactions, and dispatching them will have a visible effect on the UI. For example, the user wants the toggle the visibility of some component on the page.
The typical application would just execute a jQuery one-liner to locate the element in the DOM and make the appropriate CSS changes. This type of thing doesn't fly in Flux architectures, and it's the type of thing we should start thinking about during the skeleton architecture phase of our application. Let's implement a simple store that handles local actions:
import dispatcher from '../dispatcher'; import { EventEmitter } from 'events'; // Our "Panel" store which is an "EventEmitter" class PanelStore extends EventEmitter { constructor() { // We always need to call this when extending a class. super(); // The initial state of the store. this.state = { visible: true, items: [ { name: 'First', selected: false }, { name: 'Second', selected: false } ] }; dispatcher.register((e) => { switch (e.type) { // Toggles the visibility of the panel, which is // visible by default. case 'PANEL_TOGGLE': this.state.visible = !this.state.visible; this.emit('change', this.state); break; // Selects an object from "items", but only // if the panel is visible. case 'ITEM_SELECT': let item = this.state.items[e.payload]; if (this.state.visible && item) { item.selected = true; this.emit('change', this.state); } break; } }); } } export default new PanelStore();
The PANEL_TOGGLE
action and the ITEM_SELECT
action are two local actions handled by this store. They're local because they're likely triggered by the user clicking a button or selecting a checkbox. Let's dispatch these actions so we can see how our store handles them:
import dispatcher from './dispatcher'; import panelStore from './stores/panel'; // Logs the state of the "panelStore" when it changes. panelStore.on('change', (state) => { console.log('visible', state.visible); console.log('selected', state.items.filter( x => x.selected)); }); // This will select the first item. dispatcher.dispatch({ type: 'ITEM_SELECT', payload: 0 }); // → visible true // → selected [ { name: First, selected: true } ] // This disables the panel by toggling the "visible" // property value. dispatcher.dispatch({ type: 'PANEL_TOGGLE' }); // → visible false // → selected [ { name: First, selected: true } ] // Nothing the second item isn't actually selected, // because the panel is disabled. No "change" event // is emitted here either, because the "visible" // property is false. dispatcher.dispatch({ type: 'ITEM_SELECT', payload: 1 });
This example serves as an illustration as to why we should consider all things state-related during the skeleton architecture implementation phase. Just because we're not implementing actual UI components right now, doesn't mean we can't guess at some of the potential states of common building blocks. In this code, we've discovered that the ITEM_SELECT
action is actually dependent on the PANEL_TOGGLE
action. This is because we don't actually want to select an item and update the view when the panel is disabled.
Building on this idea, should other components be able to dispatch this action in the first place? We've just found a potential store dependency, where the dependent store would query the state of panelStore
before actually enabling UI elements. All of this from local actions that don't even talk to APIs, and without actual user interface elements. We're probably going to find many more items like this throughout the course of our skeleton architecture, but don't get hung up on finding everything. The idea is to learn what we can, while we have an opportunity to, because once we start implementing real features, things become more complicated.