Task 5: Add Posts

Step 1: API Call to Add Post

In this step we will create a new function to mock the call to an API to create a post. Open src/lib/api.ts and add the following function:

// Create a post
export const createPost = async (content: string, image?: string): Promise<Post> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const newPost: Post = { 
        id: nanoid(), 
        userId: "u1",
        content, 
        image,
        timestamp: new Date().toISOString(),
        likeCount: 0,
        commentCount: 0,
      };
      db.posts.push(newPost);
      resolve(newPost);
    }, 200); // Simulate an API delay
  });
};

The createPost function is a mock API call to create a new post. It takes two parameters: content, which represents the content of the post, and image (optional), which represents an image associated with the post.

Inside the function, a new post object is created with a unique ID generated using the nanoid() function. Other properties of the post, such as the user ID, timestamp, like count, and comment count, are also assigned. The newly created post is then added to the db.posts array, which simulates a database.

The function returns a promise that resolves to the newly created post after a simulated delay of 200 milliseconds. This delay is included to mimic the latency of a real API call.

Note that this implementation is a mock and does not actually persist the data. In a real application, the createPost function would typically make an HTTP request to a server to create a new post in the database.

In a real-world scenario, when creating a new post in a database, the database management system would typically assign a unique identifier (ID) to the post. This ID serves as a primary key and ensures that each post has a unique identification within the database.

In our mock implementation, we simulate this process by using the nanoid() function. The nanoid() function is a popular library that generates unique, URL-safe IDs based on the specific requirements of each project. It generates IDs that are highly unlikely to collide with each other, even in high-concurrency environments.

To add this library as a dependency, open the terminal and run the following command:

pnpm add nanoid

Next, import it at the top of the api.ts file:

import { nanoid } from "nanoid";

The userId: "tmp-user" attribute in the createPost function is currently set to a temporary value of u1. In a real application, this attribute would be assigned the ID of the user creating the post. By associating the user ID with the post, we can track and display the posts made by each user.

However, for the purpose of focusing on the process of adding a post, we are currently assigning every newly created post to the u1 which is Edsger Dijkstra in our sample data! This temporary value allows us to test the functionality of creating a post without the need for user authentication or identification.

Please stage and commit all changes.

git add .
git commit -m "Mock API call to add post"

Step 2: Store Action to Add Post

To update the Zustand store and include an action to add a post to our global state, follow these steps:

  • Open the src/lib/store.ts file.
  • Update the actions type declaration as shown below:
  type Action = {
    setPosts: (posts: PostWithUserData[]) => void;
    removePost: (id: string) => void;
+   addPost: (post: Post) => void;
    // Add more actions
  };
  • Add the following code to the useStore hook after the removePost function:
addPost: (post) => {
  const newPost: PostWithUserData = {
    ...post,
    user: {
      id: "u1",
      userName: "edsger",
      displayName: "Edsger Dijkstra",
      avatar: "edsger-dijkstra.webp",
    },
  };
  const newPosts = [...get().posts, newPost];
  set({ posts: newPosts });
},

The addPost function is a store action in the Zustand store that adds a new post to the global state.

When called, the addPost function takes a post object as a parameter, representing the new post to be added. It creates a new PostWithUserData object by spreading the properties of the post object and adding additional user information.

In this example implementation, the user associated with the new post is hard-coded as Edsger Dijkstra, with a user ID of "u1" and other details such as the user's display name, username, and avatar. In a real application, this information would typically come from user authentication or identification.

Next, the function creates a new array newPosts by spreading the existing posts in the global state and adding the new post to the end of the array.

Finally, the function updates the global state by calling the set function provided by Zustand and passing in the updated newPosts array.

This action allows components to add a new post to the global state by calling addPost and passing in the necessary post data.

Please stage and commit all changes.

git add .
git commit -m "Store action to add post"

Step 3: Update useMutationPosts

In this step we will update our custom useMutationPosts to include a addNewPost operation, as follows:

import { createPost, deletePost } from "@/lib/api";
import { useStore } from "@/lib/store";

function useMutationPosts() {
  const removePost = useStore((state) => state.removePost);
  const addPost = useStore((state) => state.addPost);

  const deletePostById = async (postId: string) => {
    await deletePost(postId);
    removePost(postId);
  };

  const addNewPost = async (content: string, image?: string) => {
    const newPost = await createPost(content, image);
    addPost(newPost);
  };

  return { deletePostById, addNewPost };
}

export default useMutationPosts;

The addNewPost function is a part of the useMutationPosts custom hook. It allows users to add a new post by making an API call to create the post and then updating the global state with the newly created post.

When called, the addNewPost function takes two parameters: content, which represents the content of the post, and image (optional), which represents an image associated with the post.

Inside the function, it calls the createPost API function, passing in the content and image parameters. This API function creates a new post and returns the newly created post object.

After receiving the new post object, the addNewPost function calls the addPost action from the Zustand store, passing in the newly created post. This action updates the global state by adding the new post to the existing posts array.

By using the addNewPost function, users can add a new post to the application, triggering the API call and updating the global state with the newly created post.

Please stage and commit all changes.

git add .
git commit -m "Update useMutationPosts to add post"

Step 4: Add Post Dialog

We will make use of the shadcn/ui Dialog component to enable a user add a new post. First, add this component to your app by running this command in the terminal:

pnpm dlx shadcn-ui@latest add dialog

We will also need to add the Label and the Input components. The latter displays a form input field and the former renders an accessible label associated with the input. Add this component to your add by running this command in the terminal:

pnpm dlx shadcn-ui@latest add label input

Next, add a new file add-post-dialog.tsx to the src/components folder with the following content:

import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlusCircledIcon } from "@radix-ui/react-icons";
import useMutationPosts from "@/hooks/use-mutations-posts";

export const AddPostDialog = () => {
  const [content, setContent] = useState("");
  const { addNewPost } = useMutationPosts();

  const handleSave = async () => {
    await addNewPost(content);
    setContent("");
  };

  const handleCancel = () => {
    setContent("");
  };

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button aria-label={"Make a Post"} variant="default" size="sm">
          <PlusCircledIcon className="w-5 h-5" />
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Add Post</DialogTitle>
          <DialogDescription>
            Provide the content of your post here.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid items-center grid-cols-4 gap-4">
            <Label htmlFor="title" className="text-right">
              Content
            </Label>
            <Input
              id="content"
              value={content}
              className="col-span-3"
              onChange={(e) => {
                setContent(e.target.value);
              }}
            />
          </div>
        </div>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant={"secondary"} type="reset" onClick={handleCancel}>
              Cancel
            </Button>
          </DialogClose>
          <DialogClose asChild>
            <Button type="submit" onClick={handleSave}>
              Save
            </Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

In this component, you might have noticed that we're making use of the useState hook to manage the content of the post. This might raise a question: "Why are we using local state when we have a global state manager like Zustand?"

The answer lies in the nature of the data we're managing:

  • Local State: The content state represents the text input by a user before they've decided to save their post. This is transient data - it only matters while the user is in the process of creating a post. Once they've saved the post or decided to cancel, this data no longer has any relevance. Thus, it doesn't make sense to keep this in our global state. By keeping it local, we ensure that only this component cares about its value, making our application more efficient and our global state cleaner.
  • Global State: Global state should ideally be reserved for data that multiple components might need to access, or data that should persist across different user interactions or even sessions. For example, once a post is saved, it might be displayed in a list of posts, edited, or deleted - actions that would be handled by different components. Thus, the saved post data belongs in the global state.

This is a good segue to talk about "Controlled Components." In the component, you will notice this piece of code:

<Input
  id="content"
  value={content}
  className="col-span-3"
  onChange={(e) => {
    setContent(e.target.value);
  }}
/>

This is a classic example of a "controlled component" in React. Here's what's happening:

  • Setting the Value: We're setting the value prop of the Input component to our local state content. This means that the display value of the input will always reflect the value of content.
  • Updating the Value: We're listening for changes to the input with the onChange prop. When the user types into the input, this event handler fires, capturing the new value and updating our local state content with it.

The term "controlled component" comes from the fact that React is in control of the input's value at all times. This is in contrast to "uncontrolled components", where the DOM itself handles the input value, and React would query the DOM for the input's value when needed. Advantages of controlled components include:

  • Predictability: Since the value of the input is directly tied to the React state, it becomes very predictable. We always know that the value in our state is what's being displayed in the input.
  • Flexibility: We can manipulate the value as needed. For instance, we could easily implement features like auto-formatting user input.

Finally, update the Sidebar component:

  import {
    HomeIcon,
    MagnifyingGlassIcon,
-   PlusCircledIcon,
  } from "@radix-ui/react-icons";
  import { Button } from "./ui/button";
+ import { AddPostDialog } from "./add-post-dialog";
	
  const Sidebar = () => {
    return (
      <div className="flex flex-col gap-2 p-4">
        <Button variant={"ghost"} size="sm">
          <HomeIcon className="w-5 h-5" />
        </Button>
        <Button variant={"ghost"} size="sm">
          <MagnifyingGlassIcon className="w-5 h-5" />
        </Button>
-       <Button size="sm">
-         <PlusCircledIcon className="w-5 h-5" />
-       </Button>
+       <AddPostDialog />
      </div>
    );
  };
	
  export default Sidebar;

Run the program and test adding a post!

Please stage and commit all changes.

git add .
git commit -m "Add Post Dialog"

Step 5: Error Handling

To prevent creating a post with empty content, we need to handle this issue at different levels of abstraction, such as the frontend or backend, or both. In this case, on the frontend, we can ensure that we don't send a (mock) API request to create a post when the content is empty. Let's update the handleSave function in the AddPostDialog component by adding a simple validation:

const handleSave = async () => {
  if (!content) {
    alert("Sorry! Content cannot be empty! 🙁");
    return;
  }
  await addNewPost(content);
  setContent("");
};

The handleSave function checks if the content variable, which represents the text content of the post, is empty. If it is empty, an alert message is displayed to the user indicating that the content cannot be empty.

If the content is not empty, the function calls the addNewPost function to add the post to the application. After the post is successfully added, the setContent function is called to reset the content variable to an empty string, clearing the input field.

This validation ensures that a post cannot be saved with an empty content, providing a better user experience and preventing unnecessary API calls for empty posts.

We can further improve the user experience by using shadcn/ui Toast component. A toast component is a user interface element that provides brief, non-intrusive messages or notifications to the user. It is typically used to display short-lived information or feedback, such as success messages, error messages, or other important updates.

First, add this component to your app by running this command in the terminal:

pnpm dlx shadcn-ui@latest add toast

Next, update the App.tsx file:

  import Sidebar from "./components/sidebar";
  import Feed from "./components/feed";
+ import { Toaster } from "./components/ui/toaster";
	
  function App() {
    return (
      <div className="flex justify-center min-h-screen">
        <Sidebar />
        <Feed />
+       <Toaster />
      </div>
    );
  }
	
  export default App;

The Toaster component is added to the App component, which is the top-level component of the application. By including the Toaster component here, we enable the display of toast notifications throughout the application.

Now, update the AddPostDialog component:

  • Import the useToast at the top of the file:
import { useToast } from "@/components/ui/use-toast";
  • Call useToast where other hooks such as useState are used:
const { toast } = useToast();
  • Replace the call to alert with a call to toast inside the handleSave function:
const handleSave = async () => {
  if (!content) {
    toast({
      variant: "destructive",
      title: "Sorry! Content cannot be empty! 🙁",
      description: `Please enter the content for your post.`,
    });
    return;
  }
  await addNewPost(content);
  setContent("");
};

As you see, the useToast hook returns a toast function that you can use to display a toast.

Run the program and test adding a post without content!

By using the Toaster component, we enhance the user experience by providing clear and concise feedback in a visually pleasing manner.

Please stage and commit all changes.

git add .
git commit -m "Show an error toast when post content is empty"

Step 6: Mock Authentication

Currently, we have hardcoded user data in createPost (inside api.ts) and addPost (inside store.ts) for simplicity. Let’s abstract this into a separate auth.ts module. By doing so, we make the code more maintainable and prepare the codebase for future integrations with actual authentication systems. When we eventually introduce a real authentication mechanism, you'd just need to update the auth.ts module without having to touch other parts of the application.

Let's implement this idea:

Create the auth.ts module (src/lib/auth.ts):

import { User } from "./types";

// Hardcoded authenticated user data for Edsger Dijkstra
const authenticatedUser: User = {
  id: "u1",
  userName: "edsger",
  displayName: "Edsger Dijkstra",
  avatar: "edsger-dijkstra.webp",
};

export const getAuthenticatedUser = (): User => {
  // For now, this simply returns the hardcoded user.
  // In the future, this can be replaced with actual authentication logic.
  return authenticatedUser;
};

Refactor the mock api.ts:

In the createPost function, use getAuthenticatedUser to retrieve the user data:

// ... other imports ...
import { getAuthenticatedUser } from "./auth";

// ... other code ...

export const createPost = async (content: string, image?: string): Promise<Post> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const user = getAuthenticatedUser();
      const newPost: Post = {
        id: nanoid(),
        userId: user.id,
        content,
        image,
        timestamp: new Date().toISOString(),
        likeCount: 0,
        commentCount: 0,
      };
      db.posts.push(newPost);
      resolve(newPost);
    }, 200); // Simulate an API delay
  });
};

Refactor the Zustand store:

Similarly, use getAuthenticatedUser in the addPost action:

// ... other imports ...
import { getAuthenticatedUser } from "./auth";

// ... other code ...

addPost: (post) => {
  const user = getAuthenticatedUser();
  const newPost: PostWithUserData = {
    ...post,
    user,
  };
  const newPosts = [...get().posts, newPost];
  set({ posts: newPosts });
},

Run the app and make sure that it works as before.

Please stage and commit all changes.

git add .
git commit -m "Add mock authentication"

Step 7: Reverse Chronological Order

Social platforms, prioritize the display of recent activities or posts, ensuring that users are always updated with the latest content. This not only keeps the content fresh but also encourages user interactions, as they often want to engage with what's current. In this section, we'll update the app to sort posts in reverse chronological order, ensuring that the newest posts always appear at the top. By implementing this strategy in our mock API and Zustand store, we emulate the behavior of major platforms and provide users with an intuitive and familiar content presentation.

Update the fetchPosts in the mock API (api.ts):

To sort the posts in reverse chronological order, we'll utilize the sort method on the array and compare the timestamp attribute of each post.

export const fetchPosts = async (): Promise<PostWithUserData[]> => {
  return new Promise((resolve) => {
    setTimeout(async () => {
      const sortedPosts = db.posts.sort((a, b) =>
        new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
      );
      const postsWithUserData = await Promise.all(
        sortedPosts.map(async (post) => ({
          ...post,
          user: await findUser(post.userId),
        })),
      );
      resolve(postsWithUserData);
    }, 200); // Simulate an API delay
  });
};

Note: The sort method modifies the array in place. We compare the timestamps by converting them to a numerical format with getTime(). A positive number will shift post b before post a, achieving the reverse chronological order.

Update the addPost in the Zustand store:

When a new post is added, we'll place it at the beginning of the array using the spread operator.

addPost: (post) => {
  const user = getAuthenticatedUser();
  const newPost: PostWithUserData = {
    ...post,
    user,
  };
  const newPosts = [newPost, ...get().posts];
  set({ posts: newPosts });
},

Note: The spread operator (...) allows us to create a new array where the new post is the first item, followed by the rest of the posts. This ensures that newly added posts appear at the top.

By making these changes, we ensure that the newest posts always appear first, both when fetched from the mock API and when added via the Zustand store. This behavior mimics most social platforms where the latest content is prioritized.

Run the app and make sure that it works as intended.

Please stage and commit all changes.

git add .
git commit -m "Display posts in reverse chronological order."