[PART 16] Creating a Twitter clone with GraphQL, Typescript, and React ( Tweets timeline )
ips-coding-challenge
Posted on January 23, 2021
Hi everyone ;).
As a reminder, I'm doing this Tweeter challenge
Github repository ( Frontend )
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
}
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.*',
]
}
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))
}),
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
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
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
}
}
`
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
Here is what its looks like:
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
},
}),
})
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])
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
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.
I think that's enough for today!
Have a nice day;)
Posted on January 23, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 23, 2021