React - Autosaving blog post to sessionStorage() with controlled inputs

Viewed 380

I am having a strange issue whilst trying to implement an auto save feature to capture my controlled inputs and save them to sessionStorage().

I have an auto-save function that runs every 30 seconds. I create an object from my input values, and then save those into sessionStorage(). I run a check to see if my created object of input values matches the currently stored object. If the new object is different, I replace the current object in sessionStorage with this new object. This seems pretty straight forward to me.

What is happening, is that I am watching the sessionStorage update one character at a time, much like how the controlled inputs I am using work when setting their values from the onChange() function. Once the object is updated fully with what I typed, it resets back to being blank.

I will show an example of the described issue with the sessionStorage below the code examples.

Here is my AddPost component, that contains the 'add post' form and the auto-save function for now:

import React, { useState, useEffect } from 'react';

//Styles
import {
  AddPostContainer,
  AddPostInfoInput,
  AddPostInfoLabel,
  AddPostTextArea,
  PostOptionWrapper,
  PostOptionGroup,
  AddPostBtn
} from './styles';

//Components
import LivePreview from './LivePreview/LivePreview';
import Icon from '../../../Icons/Icon';

const AddPost = props => {
  const [htmlString, setHtmlString] = useState('');
  const [title, setTitle] = useState('');
  const [postBody, setPostBody] = useState('');
  const [author, setAuthor] = useState('');
  const [tags, setTags] = useState('');
  const [featuredImage, setFeaturedImage] = useState('');

  const autoSave = async () => {
    const autoSaveObject = {
      title,
      author,
      tags,
      featuredImage,
      postBody
    };

    try {
      await window.sessionStorage.setItem(
        'add_post_auto_save',
        JSON.stringify(autoSaveObject)
      );
    } catch (e) {
      return;
    }
  };

  setInterval(() => {
    const currentSave = window.sessionStorage.getItem('add_post_auto_save');

    const autoSaveObject = {
      title,
      author,
      tags,
      featuredImage,
      postBody
    };

    if (currentSave === JSON.stringify(autoSaveObject)) {
      return;
    } else {
      autoSave();
    }
  }, 10000);

  return (
    <AddPostContainer>
      <AddPostInfoLabel htmlFor="title">Title</AddPostInfoLabel>
      <AddPostInfoInput
        type="text"
        value={title}
        onChange={e => setTitle(e.target.value)}
        placeholder="enter post title"
        id="title"
      />
      <AddPostTextArea
        inputwidth="100%"
        height="400px"
        value={postBody}
        onChange={e => {
          setHtmlString(e.target.value);
          setPostBody(e.target.value);
        }}
      />
      <AddPostInfoLabel htmlFor="postbody">Live Preview:</AddPostInfoLabel>
      <LivePreview id="postbody" htmlstring={htmlString} />
      <PostOptionWrapper>
        <PostOptionGroup width="33%">
          <AddPostInfoLabel htmlFor="author">Author:</AddPostInfoLabel>
          <AddPostInfoInput
            type="text"
            value={author}
            onChange={e => setAuthor(e.target.value)}
            placeholder="enter author's name"
            id="author"
            inputwidth="60%"
          />
        </PostOptionGroup>
        <PostOptionGroup width="33%">
          <AddPostInfoLabel htmlFor="tags">Tags:</AddPostInfoLabel>
          <AddPostInfoInput
            type="text"
            placeholder="enter tags separated by ,"
            value={tags}
            onChange={e => setTags(e.target.value)}
            id="tags"
            inputwidth="60%"
          />
        </PostOptionGroup>
        <PostOptionGroup width="33%">
          <AddPostInfoLabel htmlFor="featuredImage">
            Feat. Image:
          </AddPostInfoLabel>
          <AddPostInfoInput
            type="text"
            placeholder="enter image url"
            value={featuredImage}
            onChange={e => setFeaturedImage(e.target.value)}
            id="featuredImage"
            inputwidth="60%"
          />
        </PostOptionGroup>
      </PostOptionWrapper>
      <AddPostBtn type="button">
        <Icon icon={['far', 'plus-square']} size="lg" pSize="1em">
          <p>Add Post</p>
        </Icon>
      </AddPostBtn>
    </AddPostContainer>
  );
};

export default AddPost;

Here is the auto-save function on it's own:

  const autoSave = async () => {
    const autoSaveObject = {
      title,
      author,
      tags,
      featuredImage,
      postBody
    };

    try {
      await window.sessionStorage.setItem(
        'add_post_auto_save',
        JSON.stringify(autoSaveObject)
      );
    } catch (e) {
      return;
    }
  };

  setInterval(() => {
    const currentSave = window.sessionStorage.getItem('add_post_auto_save');

    const autoSaveObject = {
      title,
      author,
      tags,
      featuredImage,
      postBody
    };

    if (currentSave === JSON.stringify(autoSaveObject)) {
      return;
    } else {
      autoSave();
    }
  }, 30000);

This auto-save function runs once every 30 seconds, and then replaces what is in sessionStorage with what the current values for the input fields are. I use JSON.stringify() on the objects to compare them. (note: the obj from sessionStorage is already stringified.). If that match returns true, nothing is saved as the current input values are also what is saved. Else, it saves the new object into sessionStorage.

My thought was that I needed to make autoSave() async, as updating both session and local storage is asynchronous and doesn't happen immediately (although pretty close to it). That didn't work.

Here is what the sessionStorage object is when it tries to save: enter image description here

It may be a lower quality, but you can see how it is updating the 'title' property. It behaves like a controlled input, character by character being added to the value.

Can someone point out what is going on here? I am at a loss on this one. Thanks in advance!

3 Answers

The main issue you have is that setInterval is being called on every render and the created intervals are never being cleared.

That means that if you type 10 characters into a text input, then you'll have 10 intervals firing every 10 seconds.

To avoid this using hooks you need to wrap your setInterval call with useEffect and return a deregistration function that will clear the interval when re-rendering (or on unmount). See the Effects with Cleanup documentation.

Here is the minimal updated version using useEffect:

    const autoSave = (postData) => {
        try {
            window.sessionStorage.setItem(
                'add_post_auto_save',
                JSON.stringify(postData)
            );
        } catch (e) {
        }
    };

    useEffect(() => {
        const intervalId = setInterval(() => {
            const autoSaveObject = {
                title,
                author,
                tags,
                featuredImage,
                postBody
            };

            const currentSave = window.sessionStorage.getItem('add_post_auto_save');
            if (currentSave === JSON.stringify(autoSaveObject)) {
                return;
            } else {
                autoSave(autoSaveObject);
            }
        }, 10000);

        return () => {clearInterval(intervalId)};
    });

If you don't want to clear and recreate the interval on every render you can conditionally control when the effect is triggered. This is covered in the useEffect Conditionally firing an effect documentation. The main thing is that you'll need to pass in every dependency of the useEffect, which in your case is all of your state variables.

That would look like this - and you would need to make sure that you include every state variable that is used inside the useEffect hook. If you forget to list any of the variables then you would be setting stale data.

    useEffect(() => {

        //... your behaviour here

    }, [title, author, tags, featuredImage, postBody]);

Further reading:

Here's a blog post from Dan Abramov that delves more into hooks, using setInterval as an example: https://overreacted.io/making-setinterval-declarative-with-react-hooks/


Also, you don't need to have five or six separate useState calls if it makes sense for the post data to always be "bundled" together.

You can store the post data as an object in useState instead of managing them all separately:

    const [postData, setPostData] = useState({
        htmlString: '',
        title: '',
        author: '',
        tags: '',
        featuredImage: '',
        postBody: '',
    });

    function updateData(value, key) {
        setPostData((prevData) => {
            return {
                ...prevData,
                [key]: value
            };
        });
    }

    const autoSave = (postData) => {
        try {
            window.sessionStorage.setItem(
                'add_post_auto_save',
                JSON.stringify(postData)
            );
        } catch (e) {}
    };

    useEffect(() => {
        const intervalId = setInterval(() => {
            const currentSave = window.sessionStorage.getItem('add_post_auto_save');

            if (currentSave === JSON.stringify(postData)) {
                return;
            } else {
                autoSave(postData);
            }
        }, 10000);

        return () => {
            clearInterval(intervalId)
        };
    }, [postData]);


    // jsx:
    <AddPostInfoInput
        type="text"
        value={postData.title}
        onChange={e => updateData(e.target.value, 'title')}
        placeholder="enter post title"
        id="title"
        />

Good question and nice formatting too. Your problem is happening because you are creating a new interval each time your component updates, that is a lot since you are using controlled inputs. I guess you can get what you want changing your component to a class component and create the setInterval on the componentDidMount method. And don't forget to clean the interval on the component unmounting, here is an example:

import ReactDOM from "react-dom";
import React from "react";

class Todo extends React.Component {
  state = {
    text1: "",
    text2: "",
    interval: null
  };

  componentDidMount() {
    this.setState({
      interval: setInterval(() => {
        const { text1, text2 } = this.state;

        const autoSaveObject = {
          text1,
          text2
        };

        console.log(JSON.stringify(autoSaveObject));
      }, 3000)
    });
  }

  componentWillUnmount() {
    clearInterval(this.state.interval);
  }

  render() {
    return (
      <div>
        <h1>TODO LIST</h1>
        <form>
          <input
            value={this.state.text1}
            onChange={e => this.setState({ text1: e.target.value })}
          />
          <input
            value={this.state.text2}
            onChange={e => this.setState({ text2: e.target.value })}
          />
        </form>
      </div>
    );
  }
}

ReactDOM.render(<Todo />, document.getElementById("root"));

try to useEffect rather the setInterval as this shape this code will make update setItem in session when the state element is change you can get the getItem when any time you need

const [state,setState]=useState({
     htmlString:"",
     title:"",
     postBody:"",
     author:"",
     tags:"",
     featuredImage:""
})


          useEffect(async () => {
               const autoSaveObject = { ...state };

               try {
                    await window.sessionStorage.setItem(
                         'add_post_auto_save',
                         JSON.stringify(autoSaveObject));
               } catch (e) {
                    return console.log(e)
               }

          }, [state])
Related