Editor's Note: In this post, freelance developer Joey Anuff describes how he used Remix, Framer, and StepZen to develop layout animations without blowing past his API quotas. For the full code, visit Joey's GitHub repo at /januff/stepzen-youtube-data-api. And be sure to check out the app.

Developing layout animations without depleting my API quota - using Remix, Framer, StepZen

Here's a front-end combination I'll definitely use again: Remix's Resource Routes and Framer Motions's AnimatePresence component. Resource Routes are arbitrary serverless endpoints, one of which I'm running at './resource' to serve placeholder results for a stepped API query (sequenced using StepZen.)

It's a fairly quota-expensive query, the kind you want to keep as far from your local dev server as possible–especially when you're fiddling with web animations, which often demand endless browser reloads to make presentable. But with my YouTube query mocked in a static Resource Route, duplicating Dev Ed's layout animations in his recent Awesome Filtering Animation with React Tutorial video was a lot less stressful.

DevEdVideoThumbnail

I was just wrapping up Kent C. Dodd's epic six-hour Remix tutorial when Simo Edwin's React animation demo showed up in my YouTube feed. The last time I'd played with Framer Motion, I'd run into problems with my exit animations during big container swaps (like list changes and route changes) but I was relieved to see Ed adroitly handling exit animations towards the video's end.

DevEdVideoThumbnail

With the last section of Dodd's mega Remix tutorial fresh in mind, it struck me that a Resource Route would be the perfect place to serve cut-and-pasted API results, safe to refetch a few million times while I messed around with animated layouts.

jokes#resource-routes

So that's what I did. I hopped over to my local StepZen dev server and copied my preferred test response: the top 5,000 highest rated comments on Adult Swim's YouTube channel.

cutAndPaste-StepZen

And then pasted it into a Remix route I named resource.tsx, a LoaderFunctionthat always returns the same mock JSON response:

vscodeResource-open

Easily testable in the browser at my /resource route:

chromeResource

And just as easily fetched in my index page loader:

export const loader: LoaderFunction = async () => {
  let res = await fetch('https://remix-resource-routes.vercel.app/resource')

  let fakeData = await res.json()
  console.log('fakeData from loader', fakeData)

  ...

Remember that your server-side console logs will show up in the terminal running your Remix dev server, not in the browser console.

consoleFakeData-Remix

With Ryan Florence's advocacy for API data pruning as an ideal server-side computation fresh in mind, I used the same index page LoaderFunction to map my 50 sets of 100 comments into two sorted arrays: Most Liked and Most Replied, whose top 100s are returned to the client.

let commentsArray: any[] = []
fakeData.data.channelByQuery?.videos.map(video => {
  video.comments.map(comment => {
    commentsArray.push({
      ...comment, 
      videoTitle: video.videoTitle, 
      videoId: video.videoId, 
      videoThumbnail: video.videoThumbnail 
    })
  })
})
// console.log('commentsArray', commentsArray)

let likeSorted = [...commentsArray].sort((a, b) => { 
  return b.likeCount - a.likeCount
})
// console.log('likeSorted', likeSorted)

let replySorted = [...commentsArray].sort((a, b) => { 
  return b.totalReplyCount - a.totalReplyCount
})

let mostLiked = likeSorted.slice(0, 100)
let mostReplied = replySorted.slice(0, 100)

return {mostLiked, mostReplied};

Both of which Remix easily provides to the index page component via its useLoaderData hook:

export default function Index() {
  const [liked, setLiked] = useState(true)
  const {mostLiked, mostReplied} = useLoaderData(); 
  
  useEffect(() => {}, [liked])

  console.log('mostLiked comments from component', mostLiked)
  console.log('mostReplied comments from component', mostReplied)

  ...

(Which you can console.log in the browser.)

consoleSorts-Chrome

Note the requirement of a key prop when rendering lists in React: when I originally chose YouTube API fields when designing my StepZen query, among the values I discarded were the comments' individual IDs, or anything else uniquely identifying I could use as a comment key (besides the comments themselves.) But updating my StepZen schema to include YouTube’s assigned comment ID was notably simple. Referring back to my unabridged API results in Postman, I saw that YouTube includes a comment's id in a few places:

YouTubeUnabridged-Postman

Leaving me to make just two changes to my GraphQL file: adding a commentId field to my Comment type, and specifying its path in the setters arguments of my @rest-powered commentsByVideoId query:

...

type Comment {
  commentId: String
  textDisplay: String
  authorDisplayName: String
  authorProfileImageUrl: String
  likeCount: Int
  totalReplyCount: Int
}

type Query { 

...

  commentsByVideoId(videoId: String!): [Comment]
    @rest(
      endpoint: "https://youtube.googleapis.com/youtube/v3/commentThreads?key=$key&videoId=$videoId&part=snippet&order=relevance&maxResults=20" 
      configuration: "youtube_config"
      resultroot: "items[].snippet"
      setters: [
        { field: "commentId",
          path: "topLevelComment.id" },
        { field: "textDisplay",
          path: "topLevelComment.snippet.textDisplay" },
        { field: "authorDisplayName",
          path: "topLevelComment.snippet.authorDisplayName" },
        { field: "authorProfileImageUrl",
          path: "topLevelComment.snippet.authorProfileImageUrl" },
        { field: "likeCount",
          path: "topLevelComment.snippet.likeCount" }
      ]
    )
}

Now that I had my data properly keyed, duplicating Dev Ed's layout animation came down to correctly copying in a few crucial elements. Most important of which was starting with a similar grid-template-columns formula, which has the interesting auto-fit we're animating:

section {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  column-gap: 1em;
  row-gap: 1em;
}

The trick to getting all phases of your list items' animations firing correctly seems to be including layout tags in the motion-tagged container and children, and an imported AnimatePresence component wrapping the iterable:

<motion.section layout>
  <AnimatePresence>
    { liked ? 
      mostLiked.map((comment) => {
        return <Comment
                key={comment.commentId} 
                liked={liked} 
                comment={comment} />; 
      }) : 
      mostReplied.map((comment) => {
        return <Comment
                key={comment.commentId}  
                liked={liked}
                comment={comment} />; 
      })  
    }
  </AnimatePresence>
</motion.section>

Item-specific Framer-Motion animation declarations are duly relegated to your item component (in this case Comment):

import { motion } from "framer-motion";

export function Comment({ comment, liked }) {
  return (
    <motion.article 
      layout
      transition={{ duration: 0.8 }}
      animate={{ x: 0, opacity: 1 }}
      initial={{ x: 800, opacity: 0 }}  
      exit={{ x: -800, opacity: 0 }}
      whileHover={{
        background: `rgba(0, 0, 0, 0.3) url(${comment.videoThumbnail})`,
        backgroundSize: "cover", 
        backgroundBlendMode: "multiply",
        backgroundPosition: "center",
        transition: { duration: 0.2 }
      style={{
        background: `rgba(0, 0, 0, 0.8) url(${comment.videoThumbnail})`,
        backgroundSize: "cover", 
        backgroundBlendMode: "multiply",
        backgroundPosition: "center",}}  
      }}>
      <header dangerouslySetInnerHTML={{__html: comment.videoTitle }}>
      </header>
      <section>
        <span dangerouslySetInnerHTML={{__html: comment.textDisplay }} />
        –{comment.authorDisplayName}
      </section>
      <footer>
        { liked ? 
          `${Number(comment.likeCount).toLocaleString('en', {useGrouping:true})} Likes` :
          `${Number(comment.totalReplyCount).toLocaleString('en', {useGrouping:true})} Replies`
        }
      </footer>
    </motion.article>
  );
}

Winding us up with an endlessly tweakable list re-order animation, gracefully transitioning a mess of CSS properties out of the box.

chromeView-animated stepzen-youtube-data-api.vercel.app

Leaving me free, when ready, to swap in my active StepZen endpoint and operate with live API data.