Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to inject a service into svelte grandchild components?

I have several service classes (with some "get data for these params" and some "calculate stuff for these params" methods) I'd like to inject into several components in my Svelte component hierarchy. At the moment, I'm seeing the following options for that, none of them very attractive:

  • Pass the services as props. Some intermediate components don't need the services and would just pass them on. And it bloats the number of props.
  • Wrap the services with a store. This feels like misusing the reactive store feature for something it was not meant for. The data that comes out of the service is mostly static and not very reactive.
  • Use the services in the top-level component, pass down the results as props to child components. This would bloat the number of props even more, as I have some "layout" components in between the root and the child components. Those layout components would then have to pass on all the props.

In Vue, I would write a plugin that adds to the properties available in all Vue components. What is the Svelte way to do this?

like image 540
chiborg Avatar asked Oct 16 '22 10:10

chiborg


1 Answers

I ran into this same issue. When there's no setup needed it's easy enough to do what Rich Harris suggested and export the services from a separate JS file, but I had a wrinkle: lazy loading. Different from your issue, but similar in that the services aren't immediately available.

I wanted to lazy load Firebase with a dynamic import since it's fairly heavy. That meant the services didn't have access to the DB until the import() promise resolved, so exporting them wasn't as straightforward. I found a couple solutions:

If you want to keep the services static, use context

You mentioned a store seems like overkill since services are static. You can use context for this! One catch: setContext needs to be called synchronously during initialization (i.e. not in onMount). If your config happens synchronously you should be able to avoid the extra layer, but here's what I did:

Create an extra component at the top of the app to instantiate the services and pass them as a prop into the root App, where you pass them to setContext. You need the extra component above App to ensure the services exist before setting them in context. Using Svelte's {#await promise} syntax, you can make sure the initialization happens before you pass the prop:

InitServices.svelte

<script>
  import config from './config';
  import createServices from './services';
  import App from './App.svelte';

  const services = createServices(config);
</script>

{#await promise then services}
  <App {services} />
{/await}

App.svelte

<script>
  import Child from './Child.svelte';
  export let services;

  setContext('services', services);
</script>

<Child />    <-- contains Grandchild.svelte

Grandchild.svelte

<script>
  const services = getContext('services');
  const promise = services.getData();
</script>

{#await promise then data}
  <div>Hello {data.username}!</div>
{/await}

If you have component tests, use a store

Wrinkle #2: I had written some tests with @testing-library/svelte, and they were now complaining about the services coming from getContext since there wasn't a parent calling setContext.

One solution is to create a TestContextWrapper.svelte, which works but adds some extra complexity (not a lot, but it's not nothing). Here's an example of that: https://svelte-recipes.netlify.app/testing/#testing-the-context-api

So... I decided to replace context with a store and it ended up working great. The reactive store is a benefit for testing since you can instantiate the store with mocked services and update it dynamically when you need to.

I still passed the services in as a prop at the top of my app to avoid null checks, but otherwise this cleaned things up a lot. services-store.js

import { writable } from 'svelte/store';

export const serviceStore = writable(null);

InitServices.svelte (Same as above)

App.svelte

<script>
  import serviceStore from './services-store';
  import Child from './Child.svelte';

  export let services;

  serviceStore.set(services);
</script>

<Child />    <-- contains Grandchild.svelte

Grandchild.svelte

<script>
  import serviceStore from './services-store';

  const promise = $serviceStore.getData();
</script>

{#await promise then data}
  <div>Hello {data.username}!</div>
{:catch err}
  <p>Error! {err.message}</p>
{/await}

Grandchild.test.js

import serviceStore from './services-store';

let mockService;
beforeEach(async () => {
  mockService = { getData: jest.fn().mockResolvedValue('helloitsjoe') };
  serviceStore.set(mockService);
});

...

it('shows username', async () => {
  render(Grandchild);
  const name = await findByText('helloitsjoe');
  expect(name).toBeTruthy();
});

it('shows error', async () => {
  // Override mock service
  mockService.getData.mockRejectedValue(new Error('oh no!'));
  render(Grandchild);
  const message = await findByText('oh no!');
  expect(message).toBeTruthy();
});
like image 129
helloitsjoe Avatar answered Dec 25 '22 23:12

helloitsjoe