DEV Community

loading...
Cover image for [PART 16] Creating a Twitter clone with GraphQL, Typescript, and React ( Tweets timeline )

[PART 16] Creating a Twitter clone with GraphQL, Typescript, and React ( Tweets timeline )

ipscodingchallenge profile image ips-coding-challenge Updated on ・5 min read

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository ( Backend )

Github repository ( Frontend )

Db diagram

Feed

While working on the feed, I noticed that I was doing too many SQL requests. I decided to delete the "counts" dataloaders and get the count directly in the feed function

src/TweetResolver.ts

async feed(@Ctx() ctx: MyContext) {
    const { db, userId } = ctx

    const followedUsers = await db('followers')
      .where({
        follower_id: userId,
      })
      .pluck('following_id')

    const tweets = await db('tweets')
      .whereIn('user_id', followedUsers)
      .orWhere('user_id', userId)
      .orderBy('id', 'desc')
      .select(selectCountsForTweet(db))
      .limit(20)

    return tweets
  }
Enter fullscreen mode Exit fullscreen mode

And for the selectCountsForTweet():

utils/utils.ts

export const selectCountsForTweet = (db: Knex) => {
  return [
    db.raw(
      '(SELECT count(tweet_id) from likes where likes.tweet_id = tweets.id) as "likesCount"'
    ),
    db.raw(
      `(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'comment') as "commentsCount"`
    ),
    db.raw(
      `(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'retweet') as "retweetsCount"`
    ),
    'tweets.*',
  ]
}
Enter fullscreen mode Exit fullscreen mode

I learned that I have to add double quotes around the count name to have camelCase name ;). Therefore, I'll not have to change my graphQL queries. I will also need this function in the parentTweetDataloader.

src/dataloaders

parentTweetDataloader: new DataLoader<number, Tweet, unknown>(async (ids) => {
    const parents = await db('tweets')
      .whereIn('id', ids)
      .select(selectCountsForTweet(db))
    return ids.map((id) => parents.find((p) => p.id === id))
  }),
Enter fullscreen mode Exit fullscreen mode

Enough for the backend. I let you check the code on the Github Repository

Working on the Feed

src/pages/Home.tsx

import React from 'react'
import Layout from '../components/Layout'
import Feed from '../components/tweets/Feed'

const Home = () => {
  return (
    <Layout>
      {/* Tweet Column */}
      <div className="container max-w-container flex mx-auto gap-4">
        <div className="w-full md:w-tweetContainer">
          {/* Tweet Form */}

          {/* Tweet Feed */}
          <Feed />
        </div>

        {/* Home Sidebar */}
        <div className="hidden md:block w-sidebarWidth bg-gray5 flex-none">
          Sidebar
        </div>

        {/* Hashtags */}

        {/* Followers Suggestions */}
      </div>
    </Layout>
  )
}

export default Home

Enter fullscreen mode Exit fullscreen mode

I let you check the Layout component. It's a little wrapper with the Navbar and a children prop.

The Feed component is really simple too:

src/components/tweets/feed.tsx

import { useQuery } from '@apollo/client'
import React, { useEffect } from 'react'
import { useRecoilState, useSetRecoilState } from 'recoil'
import { FEED } from '../../graphql/tweets/queries'
import { tweetsState } from '../../state/tweetsState'
import { TweetType } from '../../types/types'
import Tweet from './Tweet'

const Feed = () => {
  const [tweets, setTweets] = useRecoilState(tweetsState)
  const { data, loading, error } = useQuery(FEED)

  useEffect(() => {
    if (data && data.feed && data.feed.length > 0) {
      setTweets(data.feed)
    }
  }, [data])

  if (loading) return <div>Loading...</div>
  return (
    <div className="w-full">
      {tweets.length > 0 && (
        <ul>
          {tweets.map((t: TweetType) => (
            <Tweet key={t.id} tweet={t} />
          ))}
        </ul>
      )}
    </div>
  )
}

export default Feed
Enter fullscreen mode Exit fullscreen mode

Here is the GraphQL query:

src/graphql/tweets/queries.ts

import { gql } from '@apollo/client'

export const FEED = gql`
  query {
    feed {
      id
      body
      visibility
      likesCount
      retweetsCount
      commentsCount
      parent {
        id
        body
        user {
          id
          username
          display_name
          avatar
        }
      }
      isLiked
      type
      visibility
      user {
        id
        username
        display_name
        avatar
      }
      created_at
    }
  }
`

Enter fullscreen mode Exit fullscreen mode

And for the component:

src/components/tweets/Tweet.tsx

import React from 'react'
import { MdBookmarkBorder, MdLoop, MdModeComment } from 'react-icons/md'
import { useRecoilValue } from 'recoil'
import { userState } from '../../state/userState'
import { TweetType } from '../../types/types'
import { formattedDate, pluralize } from '../../utils/utils'
import Avatar from '../Avatar'
import Button from '../Button'
import IsLikedButton from './actions/IsLikedButton'

type TweetProps = {
  tweet: TweetType
}

const Tweet = ({ tweet }: TweetProps) => {
  const user = useRecoilValue(userState)

  const showRetweet = () => {
    if (tweet.user.id === user!.id) {
      return <div>You have retweeted</div>
    } else {
      return <div>{tweet.user.display_name} retweeted</div>
    }
  }

  return (
    <div className="p-4 shadow bg-white rounded mb-6">
      {/* Retweet */}
      {tweet.type === 'retweet' ? showRetweet() : ''}
      {/* Header */}
      <div className="flex items-center">
        <Avatar className="mr-4" display_name={tweet.user.display_name} />

        <div>
          <h4 className="font-bold">{tweet.user.display_name}</h4>
          <p className="text-gray4 text-xs mt-1">
            {formattedDate(tweet.created_at)}
          </p>
        </div>
      </div>

      {/* Media? */}
      {tweet.media && <img src={tweet.media} alt="tweet media" />}
      {/* Body */}
      <div>
        <p className="mt-6 text-gray5">{tweet.body}</p>
      </div>

      {/* Metadata */}
      <div className="flex justify-end mt-6">
        <p className="text-gray4 text-xs ml-4">
          {pluralize(tweet.commentsCount, 'Comment')}
        </p>
        <p className="text-gray4 text-xs ml-4">
          {pluralize(tweet.retweetsCount, 'Retweet')}{' '}
        </p>
      </div>

      <hr className="my-2" />
      {/* Buttons */}
      <div className="flex justify-around">
        <Button
          text="Comments"
          variant="default"
          className="text-sm"
          icon={<MdModeComment />}
          alignment="left"
        />
        <Button
          text="Retweets"
          variant="default"
          className="text-sm"
          icon={<MdLoop />}
          alignment="left"
        />

        <IsLikedButton id={tweet.id} />

        <Button
          text="Saved"
          variant="default"
          className="text-sm"
          icon={<MdBookmarkBorder />}
          alignment="left"
        />
      </div>
    </div>
  )
}

export default Tweet

Enter fullscreen mode Exit fullscreen mode

Here is what its looks like:

Feed page

I will talk later about the IsLikedButton.

Let's talk about what's a retweet. I think I should change the way I consider a retweet. For now, a retweet is a normal tweet with a parent. But in reality, I think the retweet should only have a table referencing the tweet_id and the user_id. I will change that later and reflect the behavior in the frontend ;).

ApolloClient and the cache?

ApolloClient comes with a cache and you can actually use it to update your data ( like a global store ). I tried to do that to update the tweet when the user likes a tweet. The problem is that, when a user likes/dislikes a tweet, it will rerender all the tweets. In my case, I only want to rerender the likes button. I didn't found a solution with the apolloClient so I will use recoil to store all the tweets and have more flexibility ( from my current knowledge perspective :D ).

src/state/tweetsState.ts

import { atom, atomFamily, selectorFamily } from 'recoil'
import { TweetType } from '../types/types'

export const tweetsState = atom<TweetType[]>({
  key: 'tweetsState',
  default: [],
})

export const singleTweetState = atomFamily<TweetType | undefined, number>({
  key: 'singleTweetState',
  default: selectorFamily<TweetType | undefined, number>({
    key: 'singleTweetSelector',
    get: (id: number) => ({ get }) => {
      return get(tweetsState).find((t) => t.id === id)
    },
  }),
})

export const isLikedState = atomFamily({
  key: 'isLikedTweet',
  default: selectorFamily({
    key: 'isLikedSelector',
    get: (id: number) => ({ get }) => {
      return get(singleTweetState(id))?.isLiked
    },
  }),
})

Enter fullscreen mode Exit fullscreen mode

The tweetsState store the tweets. The singleTweetState will allow us to get a single tweet using the tweetsState in the get method. Finally, the isLikedState will be interested only on the tweet's isLiked property.

Let's see everything in action:

src/components/tweets/feed.tsx

const Feed = () => {
  const [tweets, setTweets] = useRecoilState(tweetsState)
  const { data, loading, error } = useQuery(FEED)

  useEffect(() => {
    if (data && data.feed && data.feed.length > 0) {
      setTweets(data.feed)
    }
  }, [data])

Enter fullscreen mode Exit fullscreen mode

If I got data from the GraphQL Query, I save the tweets in my global store with the setTweets method.

Now let's take a look at the IsLikedButton

src/components/tweets/actions/IsLikedButton.tsx

import { useMutation } from '@apollo/client'
import React from 'react'
import { MdFavoriteBorder } from 'react-icons/md'
import { useRecoilState, useRecoilValue } from 'recoil'
import { TOGGLE_LIKE } from '../../../graphql/tweets/mutations'
import { isLikedState } from '../../../state/tweetsState'
import Button from '../../Button'

type IsLIkedButtonProps = {
  id: number
}

const IsLikedButton = ({ id }: IsLIkedButtonProps) => {
  const [isLiked, setIsLiked] = useRecoilState(isLikedState(id))

  const [toggleLike, { error }] = useMutation(TOGGLE_LIKE, {
    variables: {
      tweet_id: id,
    },
    update(cache, { data: { toggleLike } }) {
      setIsLiked(toggleLike.includes('added'))
    },
  })
  return (
    <Button
      text={`${isLiked ? 'Liked' : 'Likes'}`}
      variant={`${isLiked ? 'active' : 'default'}`}
      className={`text-sm`}
      onClick={() => toggleLike()}
      icon={<MdFavoriteBorder />}
      alignment="left"
    />
  )
}

export default IsLikedButton

Enter fullscreen mode Exit fullscreen mode

I pass the tweet_id as a prop as I need it to get the isLiked selector from the global store.

Then I use the useMutation from the apolloClient to make the toggleLike request. You can use the update key to do whatever you want once the mutation is complete. Here, I change the isLiked property. This way, only my button is re-rendered.

Toggle isLiked tweet

I think that's enough for today!

Have a nice day;)

Discussion (0)

Forem Open with the Forem app