Preserving draft comments with useGlobalState
Preserving draft comments with useGlobalState

Watch everything for $29/month.

Join Build UI Pro

Preserving draft comments with useGlobalState

Learn how to use `react-hooks-global-state` to maintain ephemeral application state.

Video resources

Summary

Today I want to talk about global state in React apps, and to set the stage, I've made this little demo here, this recreation of GitHub issues. And we have two issues right here with a comment, and the ability for us to add a response. And if we click "Add comment" here, we can just type something in, and if we hit "Comment", we'll see it show up in the alert box.

So simple demo, and to kind of motivate the problem that we're trying to solve, let's come to Issue 1, and we see Tony here is asking for the ability to auto-animate my entire app: "Hey man, thanks for the awesome library. Just have one small request, if you don't mind me asking, could you add an auto-animate prop so it just sort of automatically animates my entire application?"

"Well, Tony, that's not how this works..." So maybe you're using this and then we decide, okay, let's think about this a little bit more. "Thanks for the message! The problem with an API like this is..." And, let's say our user is starting to write this message, and we get distracted. Maybe we wanna just pop over to Issue 2 and see what this is – oh look, it's Tony again, telling us that our API didn't work. And then we come back to Issue 1 to finish our comment. And if we click "Add comment", we've lost our draft text.

So this is pretty common to see in React apps because this text area here is a React component, it's using local state, and so as soon as we navigate to Issue 2 and that component is unmounted, all of its local state goes away.

And we can see this if we come over and look at our issue page. This is a Next.js app, so this is a dynamic route right here, and we can see that when we click "Add comment", this button right here, then we just toggle some state, isShowingNewComment, and if that's true, we render this NewComment component.

So if we can go ahead and open this up, we're gonna see that this is kind of this shared component that lives up here in the components directory, and it uses local state for the text. So when we write something in here, we go ahead and set the text, and then when we click "Comment", we see that text show up in the alert. And so that's why as soon as this component is unmounted, this text is just gonna disappear.

And so how might we solve this?

Remembering state after a component unmounts

Well, this is an area where we actually don't want the state tied to the component, since as soon as we navigate away, that component is unmounted. And there's a lot of libraries that are designed to help us solve this problem, and today we're gonna be looking at one called react-hooks-global-state. So lemme show you how easy this is.

I'm gonna come here to my NewComment, and right above it, outside of the component, we're going to call a function called createGlobalState. And this is imported directly from this library right here.

import { createGlobalState } from "react-hooks-global-state";

createGlobalState();

export default function NewComment() {
  let [text, setText] = useState("");

  // ...
}

And this is a function that we can invoke, and this is going to create an external store for us that lives outside of our component, but that any component in our whole application can write to and read from, regardless of basically its lifecycle.

And so the way this works, we can see by hovering over this, is that it takes in an initial state. So let's go ahead and pass in an object, and we can give this whatever shape we want. Let's call this newCommentText, and we'll start this off as an empty string. And the return value for this is a bunch of cool stuff, including a helper called useGlobalState.

import { createGlobalState } from "react-hooks-global-state";

let { useGlobalState } = createGlobalState({
  newCommentText: "",
});

export default function NewComment() {
  let [text, setText] = useState("");

  // ...
}

So now we have our global store with a single key and value in it. If we come down to where we're using state and just replace this with useGlobalState, we're gonna see a TypeScript error right here, and if we come and take a look at this, we'll see we need to give it a key that points to basically one of the keys in our initial state object.

import { createGlobalState } from "react-hooks-global-state";

let { useGlobalState } = createGlobalState({
  newCommentText: "",
});

export default function NewComment() {
  let [text, setText] = useGlobalState("newCommentText");

  // ...
}

So just with that change, let's go ahead and save this, come over, and try this again. So I'm gonna click "Add comment" – "Hey Tony - my favorite user!" And let's go over to Issue 2, and then let's come back to Issue 1. Whoops. I forgot to submit that, I hit "Add comment" and check that out – the state is still there waiting for us right in the textarea, again because this component is really just reaching into this global store and using it for the value of our textarea!

So, that is a pretty amazing refactoring as a way to achieve this behavior. If you've been working with React for a while, you've probably used some library that solves this problem, whether it's Redux, or something like Valio, or Jotai, or Zustand. But this library, react-hooks-global-state, is actually by the same creator and maintainer of those libraries. But I just love how simple the API is here. If you see, it's almost a nearly transparent refactor to get us from using local React state to just having this external store that persists beyond components unmounting.

Storing multiple draft comments

So it's pretty awesome how simple this is to use, but if we come take a look at our app, we're actually gonna see a problem. If we come over to Issue 2, and let's say we want to add a comment here for Tony over here, we're gonna see the same state shows up right here. And in the context of this application, you know, this is a bug because this comment text should be scoped to the first issue. But right now our global store is literally a global singleton, and it only has this single key, newCommentText, so it's gonna be shared by every instance of NewComment

So what we really want to do is key the new comment text by the issue id.

So in order to do this, let's come here and instead of just having a key that's a string, let's change this to draftComments. And we'll make this an object so that we can do something like, you know, Issue 1 is gonna have a value of new text for Issue 1.

let { useGlobalState } = createGlobalState({
  draftComments: {},
});

and then we'll be able to add other issues just by key like this. And so this is what our data structure is gonna look like, which will allow us to look up the appropriate text for the given issue.

But we'll start this off as an empty object, and let's come update our signatures within our component.

So, this key right here, this is really cool how this is typed based on how we define the shape of our initial state. And so, TypeScript is already telling us we need to update this right here, so if we autocomplete, it's gonna tell us we only have one key so far on our global store, draftComments. And now this is actually an object which we can see right there, so it's no longer just text, it's actually all of our draft comments. So let's go ahead and rename this to draftComments, and we'll rename this to setDraftComments:

export default function NewComment() {
  let [draftComments, setDraftComments] = useGlobalState("draftComments");

  // ...
}

and down here, where we are setting the value in the textarea, we need to be able to look up the text for the given issue. So again, we want to make a data structure that looks something like this:

draftComments: {
  "1": "text for issue 1",
  "2": "text for issue 1",
}

So we actually need the id of the issue to be passed into this NewComment function. But right, now we don't pass that in. So let's go ahead and expose a new prop called issueId on our NewComment component. And this is gonna be a string:

export default function NewComment({ issueId }: { issueId: string }) {
  let [draftComments, setDraftComments] = useGlobalState("draftComments");

  // ...
}

And we'll open up our issue page, and this is the actual page, which is already getting the issue as a prop up here. So I'll come back down to our NewComment. We can see there's a TypeScript error, we need issueId - let's go ahead and pass it in as issue.id, just like that:

{isShowingNewComment ? (
  <NewComment issueId={issue.id} />
: (
  <button>
    {/* ... */}
  </button>
)}

So now if we come back to our NewComment, we should be able to come down to value, and we want to look up the text by issueId, just like that:

<textarea value={draftComments[issueId]} />

Now if we do this, we're actually gonna see a Type error, because TypeScript thinks draftComments is an empty object since that's what we specified up here for the initial state. But we actually want this to be an object with an arbitrary number of keys so we can kinda add new text to them as the user starts creating comments on different issues.

So we don't want this to just be a frozen empty object. And we can get around this by adding some more type information. If we look at createGlobalState, we'll see that it actually accepts a type parameter that we can use to tell TypeScript what the shape of our store should be.

So we'll come here, add a type parameter of what our store should look like. It's an object with a key draftComments. And the value is not an empty object, it's actually a Record with keys that are strings and values that are strings, just like that:

let { useGlobalState } = createGlobalState<{
  draftComments: Record<string, string>;
}>({
  draftComments: {},
});

And so now if we take a look at the IntelliSense for draftComments, we're gonna see it's not just an empty object, it is an arbitrary object with as many string keys and values as we want.

And so now down here on line 39, TypeScript's happy with that. And when we actually go to set the new comment text, well we now have to set the whole draftComments object. So, you've probably seen something like this before, let's go ahead and spread the existing draftComments object, and then we will override the current issueId with the value from the change event.

<textarea
  value={draftComments[issueId]}
  onChange={(e) => {
    setDraftComments({ ...draftComments, [issueId]: e.target.value });
  }}
/>

So let's save this, come over, and let's go back to Issue 1. Let's add a comment, "Comment for issue 1". And let's try going to Issue 2 and adding a comment.

Okay, so this looks good so far, we don't have any text here. And if we come back to Issue 1 – look at that, we still have our comment for Issue 1. And now if we add a comment for Issue 2, come back to Issue 1, we still have Issue 1's comment, and over here we have Issue 2's comments.

And the last thing here, if we go ahead and click "Comment", we're gonna see in our alert, we're alerting all the draft comments. Let's go ahead and alert the one for the current issueId, just like that:

<button onClick={() => alert(draftComments[issueId])}>Comment</button>

And so now, we see Issue 2, just like that. It's staying up to date. And Issue 1, we don't have our text anymore because Hot Refresh cleaned this out. But if we were to drop some new text and then comment, we see it there and we see Issue 2 over here.

Defaulting the panel to open if a draft comment exists

So I think this is pretty cool, hopefully you find this as interesting as I do, because look how few lines we needed in order to solve this problem. And we have complete control over the shape of our store, and how our component interacts with it. And look at this – we don't need useState anymore, the signature is almost identical to useState from React, we just useGlobalState, and point to basically a slice of our store right here.

And my favorite part is we didn't have to wire up context ourselves at all. We didn't have to set up a provider and make a new provider component that's stateful that passes it in. We don't have to useContext from components that need to read from it. And in fact, this is truly a global store in that it can be written to and read from anywhere in the application.

So let's say we want to come here and add a new update to our app where, if the user has a draft comment for the issue, when we navigate to it, let's just go ahead and open the text box for them so they don't have to click it.

So right now our store is local to this NewComment component, but let's go ahead and make it a const so we can export useGlobalState.

const { useGlobalState } = createGlobalState<{
  draftComments: Record<string, string>;
}>({
  draftComments: {},
});

export { useGlobalState }

And let's pop back over to our issue. Let's come to the top, and right here, isShowingNewComment, we always default it to false, which is why this closes every time we navigate. But let's go ahead and useGlobalState. We'll see that it's being imported from our NewComment component. We can go ahead and call that, and we'll pass in the key right here, draftComments. So this is gonna give us back draft comments and the setter, but we actually don't need the setter. We just want to know if there is a draft comment for this issue. And right here, instead of hard-coding this to false, if we have a draft comment for the current issue.id just like that, then, this will be truthy - let's go ahead and make this a boolean - and it should default to open.

import NewComment, { useGlobalState } from "@/components/new-comment";

export default function Issue({ issue }: any) {
  let [draftComments] = useGlobalState("draftComments");
  let [isShowingNewComment, setIsShowingNewComment] = useState(
    !!draftComments[issue.id]
  );

  //...
}

So let's just refresh to kind of wipe the state. Let's leave a comment for Issue 1, let's come to Issue 2, and then come back to Issue 1. Check that out. It opens for Issue 1 because we have a draft comment, but it doesn't for 2. And if we were to add some text for 2, now if we go back and forth we'll see it's open on both pages.

Other use cases

So, I think that is a great example of how you can use global state to kind of solve these sorts of issues and just improve the user experience. But again, I really like that this library in particular has such a simple API surface area. It really lets you kind of move up this gradient from starting with local state in components – which is how I always like to start my applications cause it's kind of the simplest way to deal with state in a React app, it embraces the react paradigm – and then once you run into a problem where you need this extra power, this is a small increase in complexity, which is kind of commensurate with the increase in complexity of the UI. We want there to be this text to still be on the page when we come back to it, and all we need to do is change useState to useGlobalState in order to achieve that.

And, you know, you can think of all sorts of ways you could use this: to remember if panels were open or closed, or toggles were toggled, during a life cycle of an application. Basically any ephemeral state that lives in the client of your React app, you can delegate to this global store if you want it to persist even after the component unmounts. Or if you have data that needs to be shared across components, and you don't want to have to go through the work of wiring up a context provider, using context, all that sort of thing.

And I just think it's a lot simpler than reaching for something really complex like Redux right off the bat. Again, I just love the simplicity of this API.

We use this for things like panels and toggles, remembering that stuff on different pages. We've also used it for setting up the current user. So usually when you load an application, if you have needs for auth on the client, you're going to bootstrap that in your root app component. And once you figure out if the current user is authenticated, you want to be able to share that status throughout the application with something like a useCurrentUser Hook. And you could write that Hook that would just read from and write to a single external store just like this, using this library, and every component that needs to know whether there's a current user can do that without having to worry about where it is in the tree, or wiring up context.

The last fun little point I wanna make is that this store right here, from createGlobalState, is resilient to concurrent mode in React 18. And I have a few videos explaining concurrent mode and this API from React called useSyncExternalStore. But basically the reason you don't want to do this yourself, if you just try to create an object in module scope and have components kind of share that data, is because of concurrent rendering in React 18, concurrent features in React 18.

So if you wanna learn more about that and kind of what problems this library solves through that useSyncExternalStore API, check the links in this video, and I will link to some videos where you can dive deeper into that.

But that's about it for this! There's obviously so many directions we could go from here if you're talking about how to effectively use global state in a React app. But again, I'm a huge fan of YAGNI and not jumping to something that's kind of overcomplicated for a situation that just needs a little bit of complexity. And if you find yourself in a position where you need just a little bit of global state, I think this react-hooks-global-state library is a perfect fit.

Links

Join Build UI Pro to access this video's summary and source code.

Join Build UI Pro

Watch every video, support our work, and get exclusive perks!

Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.

If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!

$29/month

Watch everything. Cancel anytime.

What you'll get as a Build UI Pro member

Full access to all Build UI videos

Get full access to all of our premium video content, updated monthly.

Private Discord

Ask questions and get answers from Sam, Ryan and other pro members.

Video summaries with code snippets

Easily reference videos with text summaries and copyable code snippets.

Source code

View the source code for every video, right on GitHub.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!