Difference between public properties (@property) and internal state (@state) in Lit components

Aug 24, 2024

Initially, I got baffled between all these in Lit components: reactive property, public property, internal state, @property(), @state(). Here is a simple diagram of how everything comes together: diagram of how everything comes together

There are two types of reactive properties public properties and internal state, and the corresponding TypeScript decorator for creating public properties with @property() decorator and for creating internal state properties with @state() decorator. There is not much difference between these types of properties and I will try to explain why. The important thing is that both of them are reactive properties with very small difference.

Now the naming made it very confusing for me to understand these concepts are they the same or how do they differ? Especially because I come from React background where state means something other than the component properties, the same thing in Vue where component properties are different than the state created with ref or reactive.

I think in the case of reactive properties all the chosen names make it hard to understand what does what and what’s the difference.

It may be beneficial to forget the meanings of names public, internal, reactive, and state altogether assuming they are used as placeholders just as a, b, or c would be. Because we already have preconceived notions of what these words mean so whenever we use them to describe something we automatically assume that the thing being described is very closely related to the meaning of the word used for describing it.

Initially, I got very confused because I started using Lit components with TypeScript and was using @property and @state decorators for creating properties, thinking they are very two different things. Where @state was something like React hook state and @property() was something like React component properties. But turns out both are pretty much the same thing and both act like React state hooks when it comes to reactivity.

But let’s get back to the topic of what the difference is between these two types of reactive properties (public properties vs internal state). And go over the basics of reactive property.

What exactly are reactive properties? They are properties of an object assigned to the properties field of the LitElement class:

class MyElement extends LitElement {
  static properties = {
    name: "Joey",
    age: 32,
  };
}

Or in typescript, we create them also using the @property() decorator:

class MyElement extends LitElement {
  @property() name?: string = "Joey";
  @property() age?: number = 32;
}

There are two types of reactive properties; public properties as you can see in the code above and internal state properties which I will demonstrate below.

And why are they reactive? Because every time we assign a new value to the reactive property Lit will initiate an update and run all the associate’s lifecycle methods and also it will rerun the component template render function. And if the render function is using a property that was just changed the newly rendered template will be different from the template we had before as we assigned a new value to the reactive property which makes the component change.

The initially rendered template has a paragraph with the text “Hello Bob”. When I change the reactive property to “some other name” we will get a paragraph with the text “Hello some other name”. Pretty basic from the mental model it is very similar to how it is in React:

class MyElement extends LitElement {
  static properties = {
    name: "Bob",
  };

  render() {
    return html`<p>Hello ${this.name}</p>`;
  }
}

public properties vs internal state (@property() vs @state)

In example above I was creating public properties:

class MyElement extends LitElement {
  static properties = {
    name: "Joey",
    age: 32,
  };
}

and TypeScript way to do it:

class MyElement extends LitElement {
  @property() name?: string = "Joey";
  @property() age?: number = 32;
}

To create internal state properties we need to do this:

class MyElement extends LitElement {
  static properties = {
    name: { state: true },
    age: { state: true },
  };

  constructor() {
    super();
    this.name = "Joey";
    this.age = 32;
  }
}

and in TypeScript:

class MyElement extends LitElement {
  @state() name?: string = "Joey";
  @state() age?: number = 32;
}

From the TypeScript example, you could assume that internal state properties are something much different than public properties but when we look at the JS class code we can see it is the same properties field on the class that we used previously except to make it internal state we are giving as a value configuration object with {state: true}. And because we did that now we need to use the constructor() function to set default values for the properties.

Also, notice that for the internal state properties in the configuration object, we are using the property name state and setting it in a true state. From a quick glance you could think that this somehow makes these internal state properties with configuration object {state: true} reactive or stateful but as I explained in paragraphs before the names @state and {state: true} have nothing to do with reactivity of properties or statefulness of them, because both public properties and internal state properties are reactive and both are holding components state. I think better less confusing naming for internal state properties could be @internalProperty() and {internal: true}

So what is the difference between public properties and internal state then? The Lit documentation says this:

Internal reactive state refers to reactive properties that are not part of the component’s public API. These state properties don’t have corresponding attributes, and aren’t intended to be used from outside the component. Internal reactive state should be set by the component itself.

That is a mouth load. So the key points are these:

  • refers to reactive properties: this means they are mostly the same as public properties.
  • not part of the component’s public API: this is more complicated because there are no clear rules on how this could be enforced in JavaScript.
  • internal state properties don’t have corresponding attributes: this is the only real difference.

Let’s address the point about how internal properties are somehow more internal and not part of the component’s public API.

For example, below I’m creating a public property name and another internal state property age:

class MyElement extends LitElement {
  static properties = {
    name: {},
    age: { state: true },
  };

  constructor() {
    super();
    this.name = "Joey";
    this.age = 32;
  }
}

customElements.define("my-element", MyElement);

Now if I try to access public property name from outside for example developer console I’m able to get the name and change it to something else:

document.querySelector("my-element").name;
//Output: "Joey"

document.querySelector("my-element").name = "Bob";
document.querySelector("my-element").name;
//Output: "Joey"

Now what happens if I try to do the same for age which is internal state property:

document.querySelector("my-element").age;
//Output: 32

document.querySelector("my-element").age = 34;
document.querySelector("my-element").age;
//Output: 34

You see no difference at all. But didn’t the documentation say not part of the component's public API yes but in JavasScript world there is no way how to impose this, now in TypeScript these properties will be marked as private or protected so while you are in TS world you will get TS safety and TS will complain if you would try to set internal state properties from the outside.

But the thing is our components get transpiled to JS from TS and are running in a browser so even if we are designing components in TS with the mindset that internal state properties are not public in reality for the users of these components these properties are as public as public properties. So the part about internal state and properties are not public is more is meant to be more like a mental model of how we should think about and treat them not that there is a difference in reality.

Now, the real difference between public properties vs internal state is that internal state properties don’t have corresponding attributes, by that we mean the ability to set and change property with attributes on the DOM custom element node and also the ability for attribute reflection:

In the example below I again have public property name and internal state property age I use both of them to render paragraphs with text inside the render function:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";

class MyElement extends LitElement {
  static properties = {
    name: {},
    age: { state: true },
  };

  constructor() {
    super();
    this.name = "Joey";
    this.age = 32;
  }

  render() {
    return html`<p>
      Hello, my name is ${this.name}, and I'm ${this.age} years old.
    </p>`;
  }
}

customElements.define("my-element", MyElement);

I will use <my-element> two times, first without attributes and second time with name and age attributes added:

<my-element></my-element> <my-element name="Bobby" age="27"></my-element>

the rendered DOM elements will look like this:

<!-- Without attributes -->
<my-element>
  <p>Hello, my name is Joey, and I'm: 32 years old.</p>
</my-element>

<!-- With attributes name="Bobby" age="27" -->
<my-element>
  <p>Hello, my name is Bobby, and I'm: 32 years old.</p>
</my-element>

Notice how we were able to change name property but not able to change age property. It is because name property is public property and age is internal state property. Also notice how both of them had nothing to do with public, internal, reactivity, or state.

In a nutshell, If I had to explain both types of reactive properties I would do it like this:

In Lit you can create to types of reactive properties, reactive properties assignable trough attributes and can have attribute reflection enabled, and reactive properties not assignable trough attributes.

Bonus tip: it turns out you can disable attribute assignability on public properties by setting attribute to false in property configuration options like this:

class MyElement extends LitElement {
  static properties = {
    name: { attribute: false },
    age: { state: true },
  };

  constructor() {
    super();
    this.name = "Joey";
    this.age = 32;
  }
}

So now actually there is no meaningful difference at all between these two properties guess I can adjust my tldr to this:

In Lit you can create two types of reactive properties, reactive properties that are assignable trough attributes sometimes and sometimes not and reactive properties not assignable trough attributes.