As a web application grows, it becomes a challenge to keep track of all data flows. Which code gets the data, what page consumes it, where and when does it need to be updated...it's easy to end up with messy code that's difficult to maintain. This is especially true when you need to share data among different pages of your app, for example user data. The concept of state management has always existed in all kinds of programs, but as web apps keep growing in complexity it's now a key point to think about during development.
In this final part, we'll look over the app we built to rethink how the state is managed, allowing support for browser refresh at any point, and persisting data across user sessions.
You need to have completed the data fetching part of the web app for this lesson. You also need to install Node.js and run the server API locally so you can manage account data.
You can test that the server is running properly by executing this command in a terminal:
curl http://localhost:5000/api
# -> should return "Bank API v1.0.0" as a result
In the previous lesson, we introduced a basic concept of state in our app with the global account
variable which contains the bank data for the currently logged in user. However, our current implementation has some flaws. Try refreshing the page when you're on the dashboard. What happens?
There's 3 issues with the current code:
- The state is not persisted, as a browser refresh takes you back to the login page.
- There are multiple functions that modify the state. As the app grows, it can make it difficult to track the changes and it's easy to forget updating one.
- The state is not cleaned up, when you click on Logout the account data is still there even though you're on the login page.
We could update our code to tackle these issues one by one, but it would create more code duplication and make the app more complex and difficult to maintain. Or we could pause for a few minutes and rethink our strategy.
What problems are we really trying to solve here?
State management is all about finding a good approach to solve these two particular problems:
- How to keep the data flows in an app understandable?
- How to keep the state data always in sync with the user interface (and vice versa)?
Once you've taken care of these, any other issues you might have may either be fixed already or have become easier to fix. There are many possible approaches for solving these problems, but we'll go with a common solution that consists in centralizing the data and the ways to change it. The data flows would go like this:
We won't cover here the part where the data automatically triggers the view update, as it's tied to more advanced concepts of Reactive Programming. It's a good follow-up subject if you're up to a deep dive.
✅ There are a lot of libraries out there with different approaches to state management, Redux being a popular option. Take a look at the concepts and patterns used as it's often a good way to learn what potential issues you may be facing in large web apps and how it can be solved.
We'll start with a bit of refactoring. Replace the account
declaration:
let account = null;
With:
let state = {
account: null
};
The idea is to centralize all our app data in a single state object. We only have account
for now in the state so it doesn't change much, but it creates a path for evolutions.
We also have to update the functions using it. In the register()
and login()
functions, replace account = ...
with state.account = ...
;
At the top of the updateDashboard()
function, add this line:
const account = state.account;
This refactoring by itself did not bring much improvements, but the idea was to lay out the foundation for the next changes.
Now that we have put in place the state
object to store our data, the next step is centralize the updates. The goal is to make it easier to keep track of any changes and when they happen.
To avoid having changes made to the state
object it's also a good practice to consider it immutable, meaning that it cannot be modified at all. It also means that you have to create a new state object if you want to change anything in it. By doing this, you build a protection about potentially unwanted side effects, and open up possibilities for new features in your app like implementing undo/redo, while also making it easier to debug. For example, you could log every changes made to the state and keep an history of the changes to understand the source of a bug.
In JavaScript, you can use Object.freeze()
to create an immutable version of an object. If you try to make changes to an immutable object, an exception will be raised.
✅ Do you know the difference between a shallow and a deep immutable object? You can read about it here.
Let's create a new updateState()
function:
function updateState(property, newData) {
state = Object.freeze({
...state,
[property]: newData
});
}
In this function, we're creating a new state object and copy data from the previous state using the spread (...
) operator. Then we override a particular property of the state object with the new data using the bracket notation [property]
for assignment. Finally, we lock the object to prevent modifications using Object.freeze()
. We only have the account
property stored in the state for now, but with this approach you can add as many properties as you need in the state.
We'll also update the state
initialization to make sure the initial state is frozen too:
let state = Object.freeze({
account: null
});
After that, update the register
function by replacing the state.account = result;
assignment with:
updateState('account', result);
Do the same with the login
function, replacing state.account = data;
with:
updateState('account', data);
We'll now take the chance to fix the issue of account data not being cleared when the user clicks on Logout.
Create a new function logout()
:
function logout() {
updateState('account', null);
navigate('/login');
}
In updateDashboard()
, replace the redirection return navigate('/login');
with return logout()
;
Try registering a new account, logging out and in again to check that everything still works correctly.
Tip: you can take a look at all state changes by adding
console.log(state)
at the bottom ofupdateState()
and opening up the console in your browser's development tools.
Most web apps needs to persist data to be able to work correctly. All the critical data is usually stored on a database and accessed via a server API, like as the user account data in our case. But sometimes, it's also interesting to persist some data on the client app that's running in your browser, for a better user experience or to improve loading performance.
When you want to persist data in your browser, there are a few important questions you should ask yourself:
- Is the data sensitive? You should avoid storing any sensitive data on client, such as user passwords.
- For how long do you need to keep this data? Do you plan to access this data only for the current session or do you want it to be stored forever?
There are multiple ways of storing information inside a web app, depending on what you want to achieve. For example, you can use the URLs to store a search query, and make it shareable between users. You can also use HTTP cookies if the data needs to be shared with the server, like authentication information.
Another option is to use one of the many browser APIs for storing data. Two of them are particularly interesting:
localStorage
: a Key/Value store allowing to persist data specific to the current web site across different sessions. The data saved in it never expires.sessionStorage
: this one is works the same aslocalStorage
except that the data stored in it is cleared when the session ends (when the browser is closed).
Note that both these APIs only allow to store strings. If you want to store complex objects, you will need to serialize it to the JSON format using JSON.stringify()
.
✅ If you want to create a web app that does not work with a server, it's also possible to create a database on the client using the IndexedDB
API. This one is reserved for advanced use cases or if you need to store significant amount of data, as it's more complex to use.
We want our users stay logged in until they explicitly click on the Logout button, so we'll use localStorage
to store the account data. First, let's define a key that we'll use to store our data.
const storageKey = 'savedAccount';
Then add this line at the end of the updateState()
function:
localStorage.setItem(storageKey, JSON.stringify(state.account));
With this, the user account data will be persisted and always up-to-date as we centralized previously all our state updates. This is where we begin to benefit from all our previous refactors 🙂.
As the data is saved, we also have to take care of restoring it when the app is loaded. Since we'll begin to have more initialization code it may be a good idea to create a new init
function, that also includes our previous code at the bottom of app.js
:
function init() {
const savedAccount = localStorage.getItem(storageKey);
if (savedAccount) {
updateState('account', JSON.parse(savedAccount));
}
// Our previous initialization code
window.onpopstate = () => updateRoute();
updateRoute();
}
init();
Here we retrieve the saved data, and if there's any we update the state accordingly. It's important to do this before updating the route, as there might be code relying on the state during the page update.
We can also make the Dashboard page our application default page, as we are now persisting the account data. If no data is found, the dashboard takes care of redirecting to the Login page anyways. In updateRoute()
, replace the fallback return navigate('/login');
with return navigate('dashboard');
.
Now login in the app and try refreshing the page, you should stay on the dashboard. With that update we've taken care of all our initial issues...
...But we might also have a created a new one. Oops!
Go to the dashboard using the test
account, then run this command on a terminal to create a new transaction:
curl --request POST \
--header "Content-Type: application/json" \
--data "{ \"date\": \"2020-07-24\", \"object\": \"Bought book\", \"amount\": -20 }" \
http://localhost:5000/api/accounts/test/transactions
Try refreshing your the dashboard page in the browser now. What happens? Do you see the new transaction?
The state is persisted indefinitely thanks to the localStorage
, but that also means it's never updated until you log out of the app and log in again!
One possible strategy to fix that is to reload the account data every time the dashboard is loaded, to avoid stall data.
Create a new function updateAccountData
:
async function updateAccountData() {
const account = state.account;
if (!account) {
return logout();
}
const data = await getAccount(account.user);
if (data.error) {
return logout();
}
updateState('account', data);
}
This method checks that we are currently logged in then reloads the account data from the server.
Create another function named refresh
:
async function refresh() {
await updateAccountData();
updateDashboard();
}
This one updates the account data, then takes care of updating the HTML of the dashboard page. It's what we need to call when the dashboard route is loaded. Update the route definition with:
const routes = {
'/login': { templateId: 'login' },
'/dashboard': { templateId: 'dashboard', init: refresh }
};
Try reloading the dashboard now, it should display the updated account data.
Now that we reload the account data every time the dashboard is loaded, do you think we still need to persist all the account data?
Try working together to change what is saved and loaded from localStorage
to only include what is absolutely required for the app to work.
Implement "Add transaction" dialog
Here's an example result after completing the assignment: