11

I would like to get the user's consent with a click on a banner button before I use LocalStorage. LocalStorage is used through redux-persist. I'm using redux and redux-persist as follows:

ReactDOM.render(
  <Provider store={store}>
    <PersistGate loading={null} persistor={persitor} >
      <MyAppRouter />
    </PersistGate>
  </Provider>,
  document.getElementById("root")
)

and store and persistor are coming from

import { createStore } from "redux"
import { persistStore, persistReducer } from "redux-persist"
import storage from "redux-persist/lib/storage"
import { rootReducer } from "../reducers/index"

const persistConfig = {
  key: "root",
  storage,
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = createStore(persistedReducer)
const persistor = persistStore(store as any)
// `as any` is necessary as long as https://github.com/reduxjs/redux/issues/2709 is not fixed

export { store, persistor }

redux-persist persists the initial state in LocalStorage as soon as the objects are created which is not what I want.

I'd like to use redux in my React components no matter whether the state is persisted or not.

I assume the details of the reducer don't matter as it doesn't have influence on where/how the state is stored.

The information that the user once has expressed consent to use LocalStorage and cookies is stored in a cookie.

How could I start using LocalStorage for persistence only after the user has expressed consent during the first visit or if the cookie is present already? The case that the cookie expired or has been deleted should be covered by the case of the first visit.

In order to minimize discussion I'm looking for a solution which solves the technical problem. The request might be overkill in terms of legal requirements.

Things I tried so far:

  • Use two store (probably bad practice) which are managed in the state of an App component which wraps Provider as shown in the example. Problem is that the page is re-rendered as soon as the banner button is clicked which is not acceptable.
  • Evaluated blacklisting which won't work because it has no impact on the initial persistence of the state.

[1] https://softwareengineering.stackexchange.com/questions/290566/is-localstorage-under-the-cookie-law

Kalle Richter
  • 8,008
  • 26
  • 77
  • 177

2 Answers2

9

The idea here is that redux-persist's persistor component is responsible for persisting data or redux state in localStorage.

In order to make a decision based on the user's consent, you need to conditionally render or not the PersistGate component

To solve such a problem you can write a custom component over Persistor which renders it only when the permission is granted. Also the logic to prompt the user to grant or deny permission can go in the same component

Example

class PermissionAndPersist extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
        permission: this.getCookie('userLocalStoragePermission')
    }
  }

  getCookie(name) {
    //implement the getc ookie method using document.cookie or a library

    /* state returned must have the following syntax
         isPermissionGranted,
         isCookieExpired
    */ 

    // The above syntax can be determined based on whether cookie is present as well as by checking the expiry data if cookie was present
  }

  render() {
      const { permission } = this.state;
      const {children, persistor} = this.props;
      if(!permission.isPermissionGranted) {
         // no permission granted, return a plain div/Fragment
         return (
            <React.Fragment>
              {children}
            </React.Fragment>
         )
      }
      if(permission.isCookieExpired) {
          return <Modal>{/* here goes a component which asks for user permission and on click updates the state as well as update in cookie */}</Modal>
      }
      // now if the cookie is present and permission is granted and cookie is not expired you render the `PersistGate` component
      return <PersistGate persistor={persitor} >{children}</PersistGate> 
  }
}

Once you create the component as above you will render it as follows

ReactDOM.render(
  <Provider store={store}>
    <PermissionAndPersist persistor={persitor} >
      <MyAppRouter />
    </PermissionAndPersist >
  </Provider>,
  document.getElementById("root")
)

Note: You can always modify the implementation of PermissionAndPersist component depending on what the requirement is, but note that the PeristGate must only be rendered when all conditions are matching. Also you might want to clear localStorage if the user doesn't grant permission

EDIT: Since the requirement is to actually not re-render the entire app on click of user banner, we need to make a few changes.

Firstly, re re-render the ModalComponent based on a condition. Secondly, we cannot conditionally change the component we re-render or else the entire app is refreshed. The only way to achieve it as of now is to actually implement the logic to persist redux state in localStorage yourself and fetch it initially on refresh

class PermissionAndPersist extends React.Component {
      constructor(props) {
        super(props)
        this.state = {
            permission: this.getCookie('userLocalStoragePermission')
        }
      }

      getCookie(name) {
        //implement the getc ookie method using document.cookie or a library

        /* state returned must have the following syntax
             isPermissionGranted,
             isCookieExpired
        */ 

        // The above syntax can be determined based on whether cookie is present as well as by checking the expiry data if cookie was present
      }

      componenDidMount() {
          const { permission } = this.state;
          const { dispatch } = this.props;
          if(permission.isPermissionGranted && !permission.isCookieExpired) {
             // Idea here is to populate the redux store based on localStorage value
             const state= JSON.parse(localStorage.get('REDUX_KEY'));
             dispatch({type: 'PERSISTOR_HYDRATE', payload: state})
          }

          // Adding a listner on window onLoad
          window.addEventListener('unload', (event) => {
             this.persistStateInLocalStorage();
          });
      }

      persistStateInLocalStorage = () => {
         const { storeState} = this.props;
         const {permission} = this.state;
         if(permission.isPermissionGranted && !permission.isCookieExpired) {
            localStorage.set('REDUX_KEY', JSON.stringify(storeState))
         }
      }
      componentWillUnmount() {
          this.persistStateInLocalStorage();
      }
      render() {
          const {children} = this.props;
          const {permission} = this.state;
          return (
            <React.Fragment>
               {children}
               {permission.isCookieExpired ? <Modal>{/*Pemission handling here*/}</Modal>}
            </React.Fragment>
          )

    }

const mapStateToProps = (state) => {
   return {
     storeState: state
   }
}
export default connect(mapStateToProps)(PermissionAndPersist);

Once you implement the above component, you need to listen to the PERSISTOR_HYDRATE action in reducer and update the redux state.

NOTE: You might need to add more handling to make the persist and rehydrate correctly, but the idea remains the same

Shubham Khatri
  • 270,417
  • 55
  • 406
  • 400
  • Thanks for your input. Afaik "Problem is that the page is re-rendered as soon as the banner button is clicked which is not acceptable." applies to this solution. – Kalle Richter Apr 18 '20 at 11:52
  • And what would you like the behaviour to be – Shubham Khatri Apr 18 '20 at 12:00
  • I'd like that persistence is activated after a click on a banner without a page re-render. I prefer this because most website don't re-render after click on the cookie banner. – Kalle Richter Apr 18 '20 at 12:02
  • This appears to be a good idea, the local storage is never written, though. – Kalle Richter Apr 18 '20 at 14:34
  • Could you please debug and see if the localStorage set function is called or not. also it should happen when the window is refreshed or page is changed or so. Also instead of unload, you could use the beforeunload event to see if that helps – Shubham Khatri Apr 18 '20 at 14:35
  • I don't think this statement is correct: "In order to make a decision based on the user's consent, you need to conditionally render or not the PersistGate component." The [docs](https://github.com/rt2zz/redux-persist/blob/master/docs/PersistGate.md) to that component say "PersistGate delays the rendering of your app's UI until your persisted state has been retrieved and saved to redux." So I think any solution must deal with the persistence mechanism, not the UI presentation. – Carl G May 31 '21 at 18:59
1

The docs for persistStore say that you can pass PersistorOptions like {manualPersist: true}, which won't persist until later calling persistor.persist(). Note that anything you've put inside a PersistGate will be gated until you start persisting.

// cookieConsent.js
import CookieConsent from "@grrr/cookie-consent"
export const BASIC_FUNCTIONALITY = 'basic-functionality';
export const cookieConsent = CookieConsent({
  ...
  cookies: [
    {
      id: BASIC_FUNCTIONALITY,
      ...
    },
    ...
  ]
});
// configureStore.js
import {cookieConsent, BASIC_FUNCTIONALITY} from './cookieConsent'

...

export default function configureStore(initialState) {

  ...

  const persistedReducer = persistReducer({
    key: 'root',
    storage,
    serialize,
    whitelist: config.reduxPersistWhitelist,
  }, createRootReducer(history))

  const store = createStore(...)

  const persistor = persistStore(store, {manualPersist: true} as PersistorOptions)
  cookieConsent.on('update', (cookies) => {
    forEach(cookies, (cookie) => {
      switch (cookie.id) {
        case APP_STATE_COOKIE_ID:
          if (cookie.accepted) {
            persistor.persist();
          } else {
            persistor.pause();
            persistor.purge().catch(console.log);
          }
          break;
      }
    }
  })

  ...
}
Carl G
  • 17,394
  • 14
  • 91
  • 115