Tucker Blackwell

Manage & share local state with confidence

🍺 🍺 5 min read

Recently, I’ve been making it a point to avoid using Redux or other state management solutions for scenarios or pieces of state that don’t really require it. I’m not going to expound upon that here, though. If you’d like to learn more about why I feel this way, check out a previous post of mine. I’d like to share the ways in which I handle CRUD (Create, Read, Update, Delete) in my apps that’s void of external state management (with the help of TypeScript). The logic itself isn’t unique, but hopefully this will get you thinking more about how to manage local state in your components.

Let’s get into it.

Let’s set up a scenario where we have an app that has CRUD associated with various posts and, for simplicity, the state for these posts is set up in a central App component. This component will fetch posts from an API, set them to local state and then pass various state updaters to its children. We’ll get to those updaters in a moment, first let’s fetch our posts.

export interface Post {
    id: string;
    content: string;
    date: Date;
}

const App: React.FC = (): JSX.Element => {
    const [posts, setPosts] = useState<Post[]>([]);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<boolean>(false);

    useEffect(() => {
        (async () => {
            try {
                const res = await fetch(postsUrl);
                const posts: Post[] = await res.json();
                await setPosts(posts);
            } catch(error) {
                setError(error)
            }
            setLoading(false)
        })();
    }, [])
}

Cool, pretty straight forward fetch on mount set up. One way we can handle CRUD is by simply passing setPosts to the children components:

//App.tsx
return (
    <PostList posts={posts} />
    <SinglePost setPosts={setPosts} />
    <CreatePost setPosts={setPosts} />
)

And in, CreatePost for example, when a post is created, we can add it to our posts state:

import React, { useState, Dispatch, SetStateAction } from 'react';
import { Post } from './App';

interface Props {
    setPosts: Dispatch<SetStateAction<Post[]>>
}

//Use TS Omit utility to omit id property that will be generated by the server (in theory)
type CreatePostRequest = <Omit<Post, 'id'>>

const initialFormState: CreatePostRequest = { content: '', date: new Date() }

const CreatePost: React.FC<Props> = ({ setPosts }): JSX.Element => {
    const [newPost, setNewPost] = useState<CreatePostRequest>(initialFormState);
    const [loading, setLoading] = useState<boolean>(true);
    const [error, setError] = useState<boolean>(false);

    const createPost = async (): Promise<void> => {
        setLoading(true);
        try {
            const res = await fetch(createPostUrl, {
                method: 'POST',
                body: JSON.stringify(newPost)
            })
            const createdPost: Post = await res.json();
            //update parent state
            setPosts(prevPosts => [...prevPosts, createdPost]);
            setNewPost(initialFormState)
        } catch(error) {
            setError(error)
        }
        setLoading(false)
    }

    ...
    
    return (
        <form onSubmit={createPost}>
            ...
        </form>
    )
}

So when the form is submitted, a request is fired to create the post, which, in turn, returns the created post. We can then add that post to our parent App’s local state. As a reminder, the initial argument to any set state function is the previous state, so we can just tack our new post onto the previous posts.

Even though passing the the state updater defined in App works, it’s a poor paradigm. There’s a burden placed on consuming components by the fact that they have to know to use the initial previous state argument. Not only this, but App is essentially allowing any component consuming its state updater to do whatever it wants with the state.

Conceptually, I like to think of any component that houses a given piece of state as server, of sorts, for that state. By that I mean, it is that state’s single source of truth, and for all intents and purposes is an API for other parts of your app. I strive to have tons of dumb components that are managed by as little smart components as possible—smart in this case referring to integral application state

CRUD with confidence

Let’s define some CRUD operators that we can pass around with a bit more assurance.

//App.tsx
const addPostToLocalState = (newPost: Post) => {
  setPosts(prevPosts => [...prevPosts, newPost])
}

const updatePostInLocalState = (updatedPost: Post) => {
  setPosts(prevPosts =>
    prevPosts.map(post => (post.id === updatedPost.id ? updatedPost : post))
  )
}

const deletePostFromLocalState = (postId: string) => {
  setPosts(prevPosts => prevPosts.filter(({ id }) => id !== postId))
}

So now when these functions are passed to the App’s children components, you should notice some key differences. First is the fact that consumers don’t have to have any knowledge of state, they just pass the required argument to the operation and it’s taken care of. Also, I like to be verbose and descriptive when naming local state updaters (and frankly everything) to not confuse myself or other developers on my team. This is particularly imperative when working in a codebase that incorporates Redux.

A point that I attempt to drive home in the post I linked above, which I’ll reiterate here, is that not every piece of your application’s state needs to be in a Redux store. I use Redux in 99% of every React app I’m working on, but it rarely has more than 2-5 properties. I’d urge you to apply a minimalist approach to the way you work with React state, and more generally programming in general. I’d feel confident in saying that your future self, and others, will thank you.

That’s about it!

Thanks for listening 👋🏻


I like to learn, build & write about things I find interesting. They often times coincide with React ⚛️. I'm currently working as a software engineer with a lovely team at Higharc.