Linked Components
One of the most powerful features of LINCD is the ability to create reusable UI components that are linked to a specific shape.
Shapes define a set of restrictions on what 'shape' the graph must have in order to be a valid instance of that shape.
For example, the Person shape that we used states that every person must have a name and states that the values of foaf:knows must be Persons as well.
When a component is linked to a shape, it ensures that the component can only be used with a valid instance of a shape.
So in our example, we can create a component that is linked to the Person shape, and we can trust that all the values of foaf:knows will be Persons.
Also, when a component is linked to a shape, it will automatically make sure that all the right data that the component needs is already loaded from the graph before the component renders. That basically means you never have to write a query! LINCD will automatically load all the data that your app needs when you use linked components.
So let's update our app to use linked components.
Note: the relevant page in the docs for linked components is here.
Which code can we make into a linked component?
Review your current Home.tsx setup.
You can see that we currenty render a list of persons like so:
<li key={person.namedNode.uri}>
{person.name} has {person.knows.size} friends: {person.knows.map((friend: Person) => friend.name).join(', ')}{' '}
</li>
let's replace this with a new component called PersonNameAndFriends:
Creating a Linked Component
To create a linked component, run the following in your terminal from the root of your project:
yarn lincd create-component PersonNameAndFriends
You should see the following output:
Info: Creating files for component 'PersonNameAndFriends'
Info: Created a new component template in /frontend/src/components/PersonNameAndFriends.tsx
Info: Created component stylesheet template in /frontend/src/components/PersonNameAndFriends.scss
Go ahead an open the file that was created in /frontend/src/components/PersonNameAndFriends.tsx.
Change SHAPE with Person (in 2 places!) and import Person from lincd-foaf/lib/shapes/Person:
import {Person} from 'lincd-foaf/lib/shapes/Person';
export const PersonNameAndFriends = linkedComponent<Person>(Person, ({source}) => {
return <div className={style.PersonNameAndFriends}></div>;
});
Linked components always receive a source prop, which will be an instance of the shape that the component is linked to.
To clarify our example, let's store the source prop as person. You can add this before the return statement:
const person = source;
Next, copy over the code inside the <li> tag from the Home.tsx file that renders a single person and paste it inside the <div> tag.
export const PersonNameAndFriends = linkedComponent<Person>(Person, ({source}) => {
const person = source;
return (
<div className={style.PersonNameAndFriends}>
{person.name} has {person.knows.size} friends: {person.knows.map((friend: Person) => friend.name).join(', ')}{' '}
</div>
);
});
As a small improvement, first let's remove the hardcoded space ({' '}) at the end of the list of friends.
And let's add some space with styles instead:
Open PersonNameAndFriends.scss and add the following styles:
.PersonNameAndFriends {
margin-bottom: 0.3rem;
padding: 0.4rem;
cursor: pointer;
&:hover {
background-color: #f9f9f9;
}
}
Note: the default LINCD App setup supports tailwindcss and SCSS, so you can choose either method. You can also use plain CSS in the .scss file if you want.
To use tailwind, you can remove everything related to SCSS and use tailwindcss classes instead. You will first have to activate tailwindcss though (guides coming soon!)
Now we can go back to Home.tsx and replace the code that renders the list of persons with the new component.
First replace the <ul> tag with a regular <div>. Then replace the <li> tag that renders the person with the new component.
This now fits on one line:
<div>
{persons.map((person) => (
<PersonNameAndFriends of={person} key={person.namedNode.uri} />
))}
</div>
Congratulations! You have just created your first linked component!
However, there is a few things that we can improve.
Automatic data loading
As it stands our component will automatically load all the data of a Person shape, but we don't actually need all of it.
We only need the name and the knows property.
So to improve the automatic data loading, we need to tell LINCD which data we need. To do this, we use 'data requests'.
As you can read in the docs for data requests,
we can do this by replacing the Person shape that we pass to linkedComponent with Person.request().
As the argument of Person.request() you can pass a function that returns an array or object with the data that we need:
export const PersonNameAndFriends = linkedComponent<Person>(Person.request(person => ({
name:person.name,
friends:person.knows
})),({source}) => {
//...
The returned data will now be available as a linkedData prop, which you can destructure into name and friends.
We don't need the source prop anymore, so we can remove it from the function arguments.
Instead, we can now directly use the values of name and friends:
export const PersonNameAndFriends = linkedComponent<Person>(
Person.request((person) => {
//return an object that contains the data that we need
return {
name: person.name,
friends: person.knows,
};
}),
({linkedData}) => {
//destructure the linkedData prop into name and friends
let {name, friends} = linkedData;
//use the values of name and friends directly
return (
<div className={style.PersonNameAndFriends}>
{name} has {friends.size} friends: {friends.map((friend: Person) => friend.name).join(', ')}{' '}
</div>
);
},
);
Great. Now LINCD will only load the data that we actually need for this component.
Child components
Even though LINCD will now only load the name and knows, there is still a problem.
That's because we also display the names of friends.
LINCD currently doesn't know that we need the name property for the friends, so it will not automatically load it from permanent storage.
In other words, the following code:
<PersonNameAndFriends of={person} />
will only load the name property for the person that we pass as the value of of, but not for the friends of this person.
With our current BackendFileStore setup from the previous step, all data is loaded into the application when it starts.
But if we were to switch to a more mature graph database at a later point, the names of the friends may stay blank.
We can fix this by rendering the name of friends with a dedicated component. This component will then request the name property itself and LINCD will know what needs to be loaded.
So let's start by creating a new component called PersonName
yarn lincd create-component PersonName
Link it to Person like before, and make sure it requests person.name and simply renders that name in a <span> tag:
frontend/src/components/PersonName.tsx:
import React from 'react';
import {registerPackageModule, linkedComponent} from '../package';
import {Person} from 'lincd-foaf/lib/shapes/Person';
import './PersonName.scss';
import {default as style} from './PersonName.scss.json';
export const PersonName = linkedComponent<Person>(
Person.request((person) => ({
name: person.name,
})),
({linkedData}) => {
let {name} = linkedData;
return <span className={style.PersonName}>{name}</span>;
},
);
//register all components in this file
registerPackageModule(module);
Let's also style the component a bit.
frontend/src/components/PersonName.scss:
.PersonName {
display: inline-block;
padding: 0.3rem 0.6rem;
margin-right: 0.4rem;
background-color: #f5f5f5;
border: 1px solid #e5e5e5;
}
We can now use this new PersonName in our PersonNameAndFriends component.
Let's start with replacing {person.name} with the new component.
To do this, we first change the data request.
We don't need the name prop anymore directly in PersonNameAndFriends, so we can remove that from the data request.
However, we do need to tell LINCD that instead it needs to load whatever data PersonName needs.
You can do that by changing this line
Person.request((person) => ({
//change this line
name: person.name,
//...
}));
Person.request((person) => ({
//to this
Name: () => PersonName.of(person),
//...
}));
This tells LINCD that it needs to load whatever the PersonName component needs, for this person.
We can then access Name (with a capital N) in the linkedData prop, and use it directly as a component:
return (
<div className={style.PersonNameAndFriends}>
<Name /> has {person.knows.size} friends: {person.knows.map((friend: Person) => friend.name).join(', ')}
</div>
);
The Name component will automatically receive the right data source that you defined in the data request, so there is no need for the 'of' argument.
Finally, we can also replace the friend.name with the PersonName component:
And to properly inform LINCD of which data to load, we need to use a SetComponent for that.
SetComponents are components that render a set of items, and for each item they render a child component. LINCD.org contains a number of these components, and you can also create your own.
For this case we will use InlineList from the lincd-design-elems package, and we will tell it to render each item as a PersonName component.
The InlineList component will then render each person as a PersonName without any extra markup. So we can expect it to render a bunch of <span> tags, simply one after the other.
First, lets install the package.
yarn add lincd-design-elems
Next, let's use this in our data request, to inform LINCD that we need to load _whatever PersonName needs, for each friend.
To do this, we replace
Person.request((person) => ({
//replace this line
friends: person.knows,
//...
}));
with
Person.request((person) => ({
//with this
FriendNames: () => InlineList.of(person.knows, PersonName),
//...
}));
This tells LINCD that we want to render an InlineList of person.knows and render each person as PersonName.
Just like before, we can now use FriendNames in our linkedData prop, and use it directly as a component:
return (
<div className={style.PersonNameAndFriends}>
<Name /> has {person.knows.size} friends: <FriendNames />
</div>
);
Finally, let's also replace person.knows.size with a linked data variable:
To do this, we add the following line to our data request:
Person.request((person) => ({
//...
numFriends: person.knows.size, //<-- add this
}));
And our final render method is:
return (
<div className={style.PersonNameAndFriends}>
<Name /> has {numFriends} friends: <FriendNames />
</div>
);
With these changes in place, you now have two fully linked components that will only load the data that they need.
The data requests that we created declare to LINCD what data is needed, and the components simply use this data. LINCD will make sure the request the right data from the right graph database (it can be configured to use any number of graph databases) and render the component when the data is ready.
No further queries, or data management is needed!
Congratulations!
In this tutorial, you have:
- built a linked data application from the ground up.
- worked with nodes to directly access the underlying graph data model.
- used existing ontologies to enhance the interoperability of your app.
- replaced the remaining low level graph access by using
Shapes. - made the storage of the graph of your app persistent.
- replaced your components with 'linked components' to automate data loading for any type of storage setup.
This makes you capable of working with LINCD at a low level by directly accessing the graph, as well as at a high level with 'linked code'. And you have gained a deeper understanding of the value of the higher level features of LINCD.
Congratulations!
Give us a shout on Discord - we love working together with developers with such a broad LINCD knowledge! And we'd love to see what you build with LINCD next!
Final code for this step
frontend/src/components/PersonNameAndFriends.tsx
import React from 'react';
import './PersonNameAndFriends.scss';
import {default as style} from './PersonNameAndFriends.scss.json';
import {registerPackageModule, linkedComponent} from '../package';
import {Person} from 'lincd-foaf/lib/shapes/Person';
import {InlineList} from 'lincd-design-elems/lib/components/InlineList';
import {PersonName} from './PersonName';
export const PersonNameAndFriends = linkedComponent<Person>(
Person.request((person) => ({
numFriends: person.knows.size,
Name: () => PersonName.of(person),
FriendNames: () => InlineList.of(person.knows, PersonName),
})),
({linkedData}) => {
let {Name, FriendNames, numFriends} = linkedData;
return (
<div className={style.PersonNameAndFriends}>
<Name /> has {numFriends} friends: <FriendNames />
</div>
);
},
);
//register all components in this file
registerPackageModule(module);
frontend/src/components/PersonName.tsx
import React from 'react';
import {registerPackageModule, linkedComponent} from '../package';
import {Person} from 'lincd-foaf/lib/shapes/Person';
import './PersonName.scss';
import {default as style} from './PersonName.scss.json';
export const PersonName = linkedComponent<Person>(
Person.request((person) => ({
name: person.name,
})),
({linkedData}) => {
let {name} = linkedData;
return <span className={style.PersonName}>{name}</span>;
},
);
//register all components in this file
registerPackageModule(module);
frontend/src/pages/Home.tsx
import {useEffect, useState} from 'react';
import {loadData} from 'lincd-foaf/lib/ontologies/foaf';
import {Person} from 'lincd-foaf/lib/shapes/Person';
import {store} from '../App';
import {PersonNameAndFriends} from '../components/PersonNameAndFriends';
loadData();
export default function Home() {
let [newName, setNewName] = useState<string>('');
let [persons, setPersons] = useState<Person[]>([]);
let [person1, setPerson1] = useState<Person>(null);
let [person2, setPerson2] = useState<Person>(null);
let [bool, setBool] = useState(false);
let forceUpdate = () => setBool(!bool);
useEffect(() => {
store.init().then(() => {
setPersons([...Person.getLocalInstances()]);
});
}, []);
let createNewPerson = function (e) {
//create a new named node for the new person
let newPerson = new Person();
//create a new edge from this new person to its own name, using the foaf.name property as the connecting edge
//to store a string in the graph, we use a Literal node.
newPerson.name = newName;
//save this node to permanent storage
newPerson.save();
//add the person to the array and update the state
setPersons(persons.concat(newPerson));
};
let onSubmitConnection = function (e) {
if (person1 && person2) {
person1.knows.add(person2);
forceUpdate();
}
};
return (
<div>
<h1>Persons</h1>
<form>
<input value={newName} onChange={(e) => setNewName(e.currentTarget.value)} type="text" placeholder="name" />
<input type="button" value="Add new person" onClick={createNewPerson} />
</form>
<hr />
<div>
{persons.map((person) => (
<PersonNameAndFriends of={person} key={person.namedNode.uri} />
))}
</div>
{persons.length > 1 && (
<form>
<SelectPerson persons={persons} setPerson={setPerson1} value={person1}></SelectPerson>
has friend
<SelectPerson persons={persons} setPerson={setPerson2} value={person2}></SelectPerson>
<input type="button" value="Make connection" onClick={onSubmitConnection} />
</form>
)}
</div>
);
}
interface SelectPersonProps {
persons: Person[];
setPerson: (person: Person) => void;
value: Person;
}
const SelectPerson = function ({persons, setPerson, value}: SelectPersonProps) {
return (
<select value={value?.namedNode.uri} onChange={(v) => setPerson(Person.getFromURI(v.currentTarget.value))}>
<option value={null}>---</option>
{persons.map((person) => (
<option value={person.namedNode.uri} key={person.namedNode.uri}>
{person.name}
</option>
))}
</select>
);
};