I got into a bit of a pickle with the user experience for my app last week. Prior to this issue coming up in my React app, I had been:
- Using Redux Toolkit to store state in localForage.
- Using redux-persist to persist and rehydrate state if the user closes, or refreshes / uses the navigation buttons on their browser.
- Generating a unique user id for each new visitor who comes to the app for the first time i.e. if their redux state was empty. This was for purposes of identification in the backend database.
The need to persist and rehydrate the state was pretty important, because what happens in the app is a series of screens whereby:
- The user fills in a number of forms which continuously saves to state;
- I then POST this state data to an API that returns a bunch of results that I also save to state; and finally
- In a subsequent screen, I display the results to the user, reading from state.
It was working really well, because once the user has results stored in state, that user could navigate back and forth, leave and return, or refresh the browser without having to re-do the forms again to get results. They could go straight to the results screen and have everything there as expected, without errors due to missing data.
The problem arose when I realised that there might be a case for us to NOT want to persist a user's data e.g. if say there were 2 people in a household, both sharing the same computer and browser. One person might have completed the forms and gotten their results (happy days!), and then asked their friend to complete the forms and get their results too. What would happen here is that the second person would be allocated the same user id as the first, the state would be overwritten with the second person's answers, and the results in the backend would be overwritten for the first user.
This wasn't the expected normal behaviour, but could feasibly happen in quite large numbers based on the general purpose of the app, so I thought I'd run an experiment on user experience to see what happens if state IS NOT persisted and rehydrated.
To test this out, the easiest way I could find to stop the persisting of data was to add the state slice in question to a blacklist. In my configureStore
function, I have the persistConfig
configuration set up and can add in blacklist: ['example']
. example
in this case is just the name of the slice.
export const configureStore = (key = 'root'): any => {
const persistConfig: PersistConfig<any> = {
key,
storage,
blacklist: ['example'],
throttle: 500,
version: 1,
// ....
};
// ...
};
As an aside, I'm not sure if any
is the right Typescript type here, so if anyone has a better suggestion, let me know! redux-persist
will still try to rehydrate the state, but because my slice has been blacklisted, there's no persisting data in the state, so nothing shows up after the rehydration.
Set up done, I then deployed it for testing. We were getting the desired effect and saving multiple users despite them using the same browser, with each one having their own set of results. Unfortunately, there were also a lot more complaints around usability and higher incidences of errors due to incomplete data whenever people were navigating back and forth across screens. If such a small group of testers were already running into these problems, there was no way this was going to work well if we deployed this live.
So back to the proverbial drawing board, the eventual solution that we settled on was a bit of a halfway house. The default behaviour assumed was that users would not be sharing their browser, so we'd persist their data as the default configuration. In the event that they did want to redo the form and get a new set of results, they'd have to tell us explicitly that this was their intention. In this case I would first clear out their state, then redirect them back to the start of the form sequence.
For the purpose of this blog post, let's assume that it's a link they click. This is how I set up the config in Redux Toolkit, using redux-persist
and Typescript.
Step 1: Make sure the blacklist option is removed. π
Obviously ignore if you didn't try that option.
Step 2: Set up the link that the user will click on to tell us they want to clear out their data and restart.
Let's say this lives in the ResultsScreen.tsx
file. Set up the onClick
handler, in this example called redoForms()
.
// ResultsScreen.tsx
// ...
const redoForms = async () => {
// we'll fill this in shortly
};
return (
// ...
<p className="my-6 w-full text-center">
Want to start again?{' '}
<span onClick={redoForms} className="cursor-pointer underline">
Click here
</span>
.
</p>
// ...
);
Step 3: Set up the function and action that will clear the redux state.
In the file where I created my slice (let's call it exampleSlice
), add a new reducer function that handles the clearing of the state. I've called it clearResults()
.
// slice.ts
const exampleSlice = createSlice({
name: 'example',
initialState: {
id: uuid(),
v1: {},
} as ExampleType,
reducers: {
// ...
clearResults() {
// Note that this should be left intentionally empty.
// Clearing redux state and localForage happens in rootReducer.ts.
},
},
})
To make this an accessible action, export it.
// slice.ts
export const { clearResults } = exampleSlice.actions;
Step 4: Amend the rootReducer, to ensure local storage is cleared.
I initially only had a single rootReducer
that was defined like this:
// rootReducer.ts
const rootReducer = combineReducers({ example });
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;
To do something specific upon the clearResults
action being dispatched, I had to introduce a new reducer (called appReducer
in this example). The line with storage.removeItem('persist:root')
is what clears out localForage. Note that storage
is an imported component where the config for my localForage sits.
// rootReducer.ts
import storage from './storage'
import { AnyAction, Reducer } from '@reduxjs/toolkit'
// ... import other components you need
const appReducer = combineReducers({ example })
const rootReducer: Reducer = (state: RootState, action: AnyAction) => {
if (action.type === 'example/clearResults') {
// this applies to all keys defined in persistConfig(s)
storage.removeItem('persist:root')
state = {} as RootState
}
return appReducer(state, action)
}
export default rootReducer
export type RootState = ReturnType<typeof appReducer>
Step 5: Hook up the onClick handler to the newly defined action
Back to the onClick
handler, we can now define what logic we want to happen. In my case, I first wanted to clear my state and localForage (through dispatching the clearResults
action). I then also wanted redirect the user back to the start of the form screens flow (in my case, it was the /
path).
Note that I made redoForms
an async
function, because I needed to await
for the clearResults
action to be completed, to be 100% sure the state and localForage was cleared out, before doing the redirection. I found that if I didn't, the redirection would happen too quickly, meaning the rehydration of the state would still occur, meaning results wouldn't be cleared.
Regarding window.location.assign
you can probably replace this with the react-router useHistory
hook if you're using it. π
// ResultsScreen.tsx
const redoForms = async () => {
// need to await to be entirely sure localForage is cleared
await dispatch(clearResults());
window.location.assign(`${appConfig.publicUrl}/`);
};
So that was the setup I used, though I'm not sure if this was THE optimal setup. If you have some improvement pointers on the code above, I'd be all ears. Let's chat on Twitter @bionicjulia.
Top comments (0)