DEV Community

loading...
Cover image for Building a Better Svelte Data Flow

Building a Better Svelte Data Flow

ryanfiller
Designer turned developer. I care about things like #javascript, #css, and #accessibility.
Originally published at ryanfiller.com on ・11 min read

This blog has been running as a Svelte-powered Sapper app since October 2020. Before changing frameworks from Gatsby I put together a test site and wrote a blog post about what I learned about configuring Sapper. I found a set up that worked for me, so I switched my site over.

The data flow I explored on my test site worked perfectly enough until I ran into a snag — I wanted to compose a post with information from two different sources without having to load data client side and rerender. I built a solution that I think combines the best of Sapper’s native data flow with mdsvex’s powerful preprocessing.

This post is a somewhat deep dive, so if it's confusing checkout the original Sapper/mdsvex post and come back after that.

If you want to skip the technical explanations of Sapper and mdsvex and get right to how I solved the problem, start here.

How does Sapper work?

Sapper uses file based routing where files and folders are used to build out the page structure of the final site. The Sapper documentation has a section about how this works, but if you're new to the framework I think it's more informative to look at the /routes directory in the sapper-template starter repo.

Each file in /routes corresponds to a url endpoint on the site. So /routes/about will produce /about.html, /routes/about/me produces /about/me.html and so on with no limit for how deep nesting goes. This post is mostly about routes that use dynamic parameters, examples of which can be found in the sapper-template's blog directory.

The sapper-template skips over this step for brevity, but normally a site will contain a collection of markdown files containing the content for each post. In order for Sapper to use this content, two dynamic routes need to exist — index.json.js and [slug].json.js.

index.json.js will use the node file system API to look through the markdown files and build a list. [slug].json.js needs to use something like unified to turn the markdown into valid HTML.

chart that shows blog/[slug].md - fs & unified() < blog/index.json, blog/[slug].json

.svelte files inside the /routes directory get access to a special preload() function that can be called from the <script context='module'> tag. Inside this function pages can access any params captured via the brackets in their file name. [slug].svelte can use params.slug and fetch() to make a call against the corresponding /[slug].json route and get data.

chart that shows browser visit - request blog/post - blog/post.svelte - fetch(`post.json`) - blog/post.json - preload(){ return data } - blog/post.html

If the app is being run in sapper build mode, [slug].svelte will fetch data and generate a page whenever a user visits a url that corresponds to an existing markdown file. Building a site with sapper export will crawl any links on a site and pre-build and statically export any pages it finds.

chart that shows blog/[slug].md - npm run sapper export - blog/[slug].hmtl

One final important Sapper concept is layouts. A file named _layout.svelte will automatically be rendered for any page within that route, with the component passed into the _layout's default <slot />. This is helpful for any elements that will be repeated on every final page, like a header and footer or navigation. One very important consideration, however, is that because of how data flows from parent to child, a _layout does not have access to the $$props object that its child receives.

char that shows log/_layout.svelte - <slot /> - blog/[slug].svelte - svelte.compile() - blog/[slug].html

How does mdsvex work?

mdsvex is a tool that lets you embed functional Svelte components right into Markdown files. It also hooks into Sapper's call to svelte.preprocess to take away some of the manual work of creating pages. mdsvex is a preprocessor for Sapper, so all of the same rules for the Sapper /routes directory still apply.

Instead of having to deal with dynamically named [slug].svelte files, mdsvex will take any files with a given extension and create Svelte components. Because these files live within the /routes directory Sapper will see them as pages and create routes for them.

!chart that shows blog/[slug].md - svelte.preprocess([ mdsvex() ]) - blog/[slug].svelte - npm run sapper export - blog/[slug].html](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u11gr370ryjegmejf6ax.png)

Under the hood, mdsvex fetches data similarly to Sapper, but uses the markdown's frontmatter to create a data object and pass it into the component as props. This takes the place of manually running fetch inside of the preload function. mdsvex will parse markdown into HTML, and can be given a Svelte layout component that will function similar to a Sapper _layout. Unlike a _layout, an mdsvex layout will be passed the $$props object.

chart that shows blog/post.md - mdsvex.compile() - {frontmatter} - $$props - template.svelte - <slot /> - blog/post.svelte

Once these Svelte pages are created, the same flow for Sapper applies. In order for mdsvex to work with Sapper, all scripts need to be run with the --ext flag. This flag will allow Sapper to treat non .svelte files as routes and pick them up during the sapper export process. This is an important distinction — instead of relying on Sapper to crawl and generate each page, mdsvex will always create a Svelte file and corresponding route. Another important caveat here is that mdsvex-generated components will not run a preload function. These genreated components are regular Svelte components and don't work the same as Sapper page routes.

chart that shows blog/[slug].md - npm run sapper export - blog/[slug].svelte - svelte.compile() - blog/[slug].html

Shortcomings

Locking in to a workflow usually comes with a series of tradeoffs. By far the largest benefit of using mdsvex is the ability to put components directly into markdown files. I was able to write over a dozen posts this way before running into any complications. mdsvex is great at taking data from one .md file and transforming it into one .svelte file, but what if data needs to come from two sources?

My real world example of this popped up when I was adding the post-as-a-series feature to my blog. Each series would have a .json file containing a name and short description. Each post in that series contains a frontmatter series field. The posts' series field would need to correspond to a series name in order to link to extra data. This would function similar to a primary key and foreign key database relationship.

One way to link between this data would be to do the work in the node code that runs to generated each post.json file.

chart that shows blog/post.md, blog/series.json > fs & unified() - blog/post.json - npm run sapper export - blog/post.svelte

A different way to do this would be to run fetch() against multiple endpoints and return one final data object to the component.

chart that shows blog/post.svelte < fetch(`post.json`) & fetch(`series.json`) = blog/post.json, blog/series.json > preload(){ return data } - blog/post.svelt

Because I'm set on using components in my markdown, I have to use mdsvex. It works a little differently as it's data sources are one-in, one-out. Getting extra data from the blog/series.json route would need to be done inside of an onMount() callback. This would happen moments after a user loads the page and wouldn't happen during server side prerendering at all. This would trigger a flash of content loading, which I really want to avoid. "Cumulative Layout Shift," or "CLS" is one of Google's new Web Vitals, so this would hurt my Lighthouse scores and SEO page rankings.

chart that shows blog/post.svelte - render - blog/post.html - onMount(){ () => fetch() } - blog/series.json - render again - blog/post.html

data being loading in after render via `onMount`

Fetching data in other places

The other option is to run fetch() from a Sapper _layout file that wraps each page, but this presents an interesting problem. Because of the way that Svelte "surgically updates the DOM," the _layout.svelte file is actually mounted and rendered once and its <slot /> content is dynamically changed out when Sapper changes routes.

You can force a refetch of a data here using a reactive statement and pass it down, but because of the way Svelte's lifecycle works this will result in the same problem where data is loaded after initial render.

Combining both page composition methods

Let's review what we know at this point —

  • Sapper can use a dynamic component to take slug from a url, turn it into a file path, then fetch and preload data.
  • fs can make a list of files, then sapper export can crawl it to produce HTML pages.
  • mdsvex will generate a component from any file in a directory, even if the routes are not crawled by Sapper.
  • In order to get data at the right time, it needs to be fetched from the preload method of a page.
  • Anything done in a preload() from a route can be server rendered by sapper export.

So what would an ideal data flow look like?

A browser (or the Sapper crawl process) would visit a url and load a dynamic [slug].svelte route. This route would then be able to call a preload function and fetch any json data, as well as find the .md content that's been transformed by mdsvex.

chart that shows browser visit - request blog/[slug] - blog/[slug].svelte - preload() < log/[slug].json, blog/[slug].md > server side render - blog/[slug].html

Getting data

This is pretty much the default Sapper workflow, but we need a way to loop in components generated by mdsvex without letting it take over as the de facto route generator. There are three important features that can work together to do this —

If a .md file lives directly inside routes/blog, mdsvex is going to turn it into a page and Sapper will load it for a given url instead of the generic [slug].svelte. The first thing to do is avoid this by moving all of the posts into a _content directory.

└─ blog/
  ├─ _content/
  │ ├─ post1/
  │ │ └─ index.md
  │ └─ post2/
  │ └─ index.md
  ├─ [slug].svelte
  └─ _series.json

Enter fullscreen mode Exit fullscreen mode

The next step is to manually find these files from [slug].svelte's preload function. For this we need to add the @rollup/plugin-dynamic-import-vars package so we can use the slug to find and load the component that mdsvex generates. import statements won't usually work with dynamic template strings, but @rollup/plugin-dynamic-import-vars will let us import from a file path that includes a slug variable.

With the plugin installed, [slug].svelte can load this file, get the default export containing the .md content and the metadata export containing the frontmatter, and still make a fetch() call to /series.json and sync up the data.

<script context='module'>
  export async function preload(page) {

    const { slug } = page.params

    const component = await import(`./_content/${slug}/index.md`)

    const series = await this.fetch(`/blog/series.json`)
      .then(response => response.json())
      .then(series => logicToLinkUpPostToSeries(...))
    )

    return {
      page: component.default,
      metadata: component.metadata,
      series: series
    }
  }
</script>

Enter fullscreen mode Exit fullscreen mode

Since all of this happens within an asynchronous preload function the page will wait to render until the data is resolved and we can avoid components loading in after initial render.

Rendering the page

There are two things to be aware of for rendering this data.

First, is component.default. If you console.log() the import it will show different two ydrthings depending on where it is logged.

// console.log(component.default) from <script context='module'> tag
{ render: [Function: render], '$$render': [Function: $$render] }

// console.log(page) from regular <script> tag
class Post { constructor(options) }

Enter fullscreen mode Exit fullscreen mode

What's going on here is best explained in the Routing section of the Sapper docs:

When a user first visits the application, they will be served a server-rendered version of the route in question, plus some JavaScript that 'hydrates' the page and initialises a client-side router. From that point forward, navigating to other pages is handled entirely on the client for a fast, app-like feel.

In short, this means that the Sapper server will render the component using the render() function to return stringified HTML. Then the Svelte client will hydrate that HTML by instantiating the class at run time in the browser. Instead of sorting this out manually, the entire component can be passed into a special <svelte:component /> tag that will automatically take care of running each at the appropriate time.

chart that shows blog/page.svelte - svelte.compile() <     function render(), class Page > <svelte:component /> - client hydration

The second concern is getting data to the correct component. Since some of the work is done behind the scenes by compilers, we can't always imperatively pass props down from one component to its direct children. Svelte provides a way around this with the context API. Rather than having to send a prop directly down the chain from parent to child, context provides a way to skip links in the chain and get data from a parent to any deeply nested child.

<App>
  <_layout>
    <mdsvexLayout>
      <[slug].svelte>
        <svelte:component this={Page} />
      </[slug].svelte>
    </mdsvexLayout>
  </_layout>
</App>

Enter fullscreen mode Exit fullscreen mode

After getting the series.json data inside of [slug].svelte we can call setContext to store data inside of a context object. Since the <Page /> component generated by mdsvex will be a child component we can call getContext and check if the current page has a value for series and the UI can act accordingly.

 // [slug].svelte
<script context='module'>
  export async function preload(page) {
    ...
  }
</script>

<script>
  export let page
  export let series
  import { setContext } from 'svelte'
  setContext('series', series)
</script>

<svelte:component this={page} />

// page.svelte
<script>
  import { getContext } from 'svelte'
  const series = getContext('series')
<script/>

{#if series}  
  ...
{/if}

Enter fullscreen mode Exit fullscreen mode

The final data flow works like this.

chart that shows blog/[slug].md, blog/[series].json > npm run export - template.svelte - <slot /> - blog/[slug].svelte - preload() < blog/[slug].json, blog/[slug].md > server side render - blog/[slug].html

My sapper-mdsvex-starter

If this sounds cool or useful to you (and I hope it does!), I've put together a starter template repo that can be used to get a blog with this data flow up and running.

This data flow is weird. I couldn't find anything else online using a similar set up, or even a different tool chain to solve the same problem. This was able to solve my use case, but if there's a better way or just any feedback, feel free to let me know in the GitHub issues, on Twitter, or on the Svelte discord where I'm @ryfill.

Special thanks

Huge thank you to everyone in the Svelte discord for helping me rubber duck through this code, especially Jacob Babich. Also thank you to kev and pngwn for fact checking this post.

Discussion (1)

Collapse
ryanfiller profile image
ryanfiller Author

This post (like all of my posts on dev.to) was originally posted on my website. This one in particular gets some extra charts there, so if any of this is unclear maybe the version with charts might help to clear that up.